Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7859d7a502 | |||
| 174fce9614 | |||
| 3dfd7ea035 | |||
| 6cb253598b | |||
| 11f4caffa8 | |||
| 80f90907d4 | |||
| 4e92ea3992 | |||
| ddbf678a8b | |||
| 315de87bfc | |||
| 26d922d3dc | |||
| ee452d41ee | |||
| 1d7d2a1dac | |||
| 41c391e87b | |||
| 1f2afe4d92 | |||
| e534d015be | |||
| d2a2f3ff6a | |||
| af05e78cdf | |||
| e566c6c9d5 | |||
| 197012e662 | |||
| e4dac5dd05 | |||
| a86bda59f6 | |||
| 5b1269cbe8 | |||
| c81a0260e2 | |||
| 2b1e6b0c3b | |||
| bff42962ba | |||
| 73e2f184cf | |||
| 0c017c4227 | |||
| 234653b549 | |||
| 23f343f1df | |||
| 88c7c1632e | |||
| 8c9fb0d45e | |||
| e54535f465 | |||
| 46d7c45ca5 | |||
| 9c32e4cbda | |||
| dc0f25aca3 | |||
| 93c78a672c | |||
| 63cae5b0ed | |||
| 81487d1dba | |||
| 6ca7e9e6a6 | |||
| e230cb47e8 | |||
| 9300a0bfb6 | |||
| 3d64115051 | |||
| 2f215ed2e5 | |||
| bda0c0c804 | |||
| bf38004b5e | |||
| f83c5dd440 | |||
| 2548aba840 | |||
| 5803559183 | |||
| 73a786879e | |||
| f4cb5d101e | |||
| 9331b5870f | |||
| dd213ddfd1 | |||
| 0990c61c93 | |||
| 5b64453101 | |||
| cbf2938c9c | |||
| 04643ad686 | |||
| 13cd5a4041 |
@@ -8,5 +8,6 @@ npm-debug.log
|
||||
/audiobooks2
|
||||
/metadata
|
||||
dev.js
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
@@ -5,5 +5,6 @@ node_modules/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/metadata/
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,44 @@
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
}
|
||||
.triangle-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-top: 8px solid rgb(34,127,35);
|
||||
border-right: 8px solid rgb(34,127,35);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
}
|
||||
|
||||
.box-shadow-md {
|
||||
box-shadow: 2px 8px 6px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-sm-up {
|
||||
box-shadow: 0px -5px 8px #11111122;
|
||||
}
|
||||
|
||||
.box-shadow-md-up {
|
||||
box-shadow: 0px -8px 8px #11111144;
|
||||
}
|
||||
|
||||
.box-shadow-lg-up {
|
||||
box-shadow: 0px -12px 8px #111111ee;
|
||||
}
|
||||
|
||||
.box-shadow-xl {
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,16 @@
|
||||
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-24 top-0 bottom-0">
|
||||
<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">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center text-gray-500">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-32 top-0 bottom-0">
|
||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
||||
</div>
|
||||
<div class="flex my-2">
|
||||
@@ -27,7 +36,7 @@
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
|
||||
<controls-playback-speed-control v-model="playbackRate" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
@@ -58,6 +67,8 @@
|
||||
</div>
|
||||
|
||||
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,7 +77,11 @@ import Hls from 'hls.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean
|
||||
loading: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -84,18 +99,23 @@ export default {
|
||||
audioEl: null,
|
||||
totalDuration: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false
|
||||
seekLoading: false,
|
||||
showChaptersModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
token() {
|
||||
return this.$store.getters.getToken
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) {
|
||||
return
|
||||
@@ -110,7 +130,7 @@ export default {
|
||||
}
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
console.warn('SEEK TO', this.$secondsToTimestamp(time))
|
||||
|
||||
this.audioEl.currentTime = time
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
@@ -130,12 +150,22 @@ export default {
|
||||
},
|
||||
updatePlaybackRate(playbackRate) {
|
||||
if (this.audioEl) {
|
||||
console.log('UpdatePlaybackRate', playbackRate)
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
try {
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
this.audioEl.defaultPlaybackRate = playbackRate
|
||||
} catch (error) {
|
||||
console.error('Update playback rate failed', error)
|
||||
}
|
||||
} else {
|
||||
console.error('No Audio El updatePlaybackRate')
|
||||
}
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.updatePlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.totalDuration
|
||||
@@ -351,11 +381,12 @@ export default {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
|
||||
}
|
||||
}
|
||||
console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
var audio = this.$refs.audio
|
||||
audio.volume = this.volume
|
||||
audio.playbackRate = this.playbackRate
|
||||
audio.defaultPlaybackRate = this.playbackRate
|
||||
|
||||
this.hlsInstance.attachMedia(audio)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
// console.log('[HLS] MEDIA ATTACHED')
|
||||
@@ -375,10 +406,13 @@ export default {
|
||||
}
|
||||
})
|
||||
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
|
||||
console.warn('[HLS] Destroying HLS Instance')
|
||||
console.log('[HLS] Destroying HLS Instance')
|
||||
})
|
||||
})
|
||||
},
|
||||
showChapters() {
|
||||
this.showChaptersModal = true
|
||||
},
|
||||
play() {
|
||||
if (!this.$refs.audio) {
|
||||
console.error('No Audio ref')
|
||||
@@ -399,7 +433,6 @@ export default {
|
||||
this.staleHlsInstance = this.hlsInstance
|
||||
this.staleHlsInstance.destroy()
|
||||
this.hlsInstance = null
|
||||
console.log('Terminated HLS Instance', this.staleHlsInstance)
|
||||
}
|
||||
},
|
||||
async resetStream(startTime) {
|
||||
@@ -410,17 +443,27 @@ export default {
|
||||
this.set(this.url, startTime, true)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
this.audioEl = this.$refs.audio
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.updatePlaybackRate(settings.playbackRate)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// this.$nextTick(this.init)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,20 +2,48 @@
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
|
||||
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book">AudioBookshelf</h1>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
|
||||
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<!-- <a v-if="isUpdateAvailable" :href="githubTagUrl" target="_blank" class="flex items-center rounded-full bg-warning p-2 text-sm">
|
||||
<span class="material-icons">notification_important</span>
|
||||
<span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span>
|
||||
</a> -->
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mr-4">
|
||||
<span class="material-icons">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<span class="material-icons">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
|
||||
<nuxt-link to="/account" class="relative w-32 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ username }}</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">person</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<template v-if="userCanUpdate">
|
||||
<ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn>
|
||||
</template>
|
||||
<ui-btn v-if="userCanDelete" color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn>
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,16 +53,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
menuItems: [
|
||||
// {
|
||||
// value: 'settings',
|
||||
// text: 'Settings'
|
||||
// },
|
||||
{
|
||||
value: 'logout',
|
||||
text: 'Logout'
|
||||
}
|
||||
]
|
||||
processingBatchDelete: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -42,10 +61,31 @@ export default {
|
||||
return this.$route.name !== 'index'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
isAllSelected() {
|
||||
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
||||
},
|
||||
audiobooksShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']()
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -56,25 +96,42 @@ export default {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
console.log('Call Start Init')
|
||||
this.$root.socket.emit('scan')
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
},
|
||||
logout() {
|
||||
this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected) {
|
||||
this.cancelSelectionMode()
|
||||
} else {
|
||||
var audiobookIds = this.audiobooksShowing.map((a) => a.id)
|
||||
this.$store.commit('setSelectedAudiobooks', audiobookIds)
|
||||
}
|
||||
this.$router.push('/login')
|
||||
},
|
||||
menuAction(action) {
|
||||
if (action === 'logout') {
|
||||
this.logout()
|
||||
} else if (action === 'settings') {
|
||||
// Show settings modal
|
||||
batchDeleteClick() {
|
||||
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/audiobooks/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -83,7 +140,6 @@ export default {
|
||||
|
||||
<style>
|
||||
#appbar {
|
||||
/* box-shadow: 0px 8px 8px #111111aa; */
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,33 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
||||
<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 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">Your Audiobookshelf is empty!</p>
|
||||
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
|
||||
</div>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in groupedBooks">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="audiobook in shelf">
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!groupedBooks.length" class="w-full py-16 text-center text-xl">
|
||||
<div class="py-4">No Audiobooks</div>
|
||||
<ui-btn v-if="filterBy !== 'all' || keywordFilter" @click="clearFilter">Clear Filter</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,24 +37,79 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
width: 0,
|
||||
bookWidth: 176,
|
||||
booksPerRow: 0,
|
||||
groupedBooks: [],
|
||||
currFilterOrderKey: null
|
||||
currFilterOrderKey: null,
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
keywordFilter() {
|
||||
this.checkKeywordFilter()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
keywordFilter() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
filterOrderKey() {
|
||||
return this.$store.getters['settings/getFilterOrderKey']
|
||||
return this.$store.getters['user/getFilterOrderKey']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearFilter() {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', null)
|
||||
if (this.filterBy !== 'all') {
|
||||
this.$store.dispatch('user/updateUserSettings', {
|
||||
filterBy: 'all'
|
||||
})
|
||||
} else {
|
||||
this.setGroupedBooks()
|
||||
}
|
||||
},
|
||||
checkKeywordFilter() {
|
||||
clearTimeout(this.keywordFilterTimeout)
|
||||
this.keywordFilterTimeout = setTimeout(() => {
|
||||
this.setGroupedBooks()
|
||||
}, 500)
|
||||
},
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
setGroupedBooks() {
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
@@ -66,6 +134,7 @@ export default {
|
||||
},
|
||||
calculateBookshelf() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
|
||||
var booksPerRow = Math.floor(this.width / this.bookWidth)
|
||||
this.booksPerRow = booksPerRow
|
||||
},
|
||||
@@ -76,6 +145,9 @@ export default {
|
||||
return null
|
||||
},
|
||||
init() {
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
this.calculateBookshelf()
|
||||
},
|
||||
resize() {
|
||||
@@ -88,11 +160,17 @@ export default {
|
||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||
this.setGroupedBooks()
|
||||
},
|
||||
settingsUpdated() {
|
||||
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
|
||||
settingsUpdated(settings) {
|
||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
||||
this.setGroupedBooks()
|
||||
}
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
@@ -100,7 +178,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.$store.dispatch('audiobooks/load')
|
||||
this.init()
|
||||
@@ -108,7 +186,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('settings/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<p class="font-book">{{ numShowing }} Audiobooks</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" />
|
||||
<span class="px-4 text-sm">by</span>
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
|
||||
|
||||
<ui-text-input v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
||||
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,12 +17,21 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
settings: {}
|
||||
settings: {},
|
||||
hasInit: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
numShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
},
|
||||
_keywordFilter: {
|
||||
get() {
|
||||
return this.$store.state.audiobooks.keywordFilter
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -30,15 +42,24 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
// Send to server
|
||||
this.$store.commit('settings/setSettings', this.settings)
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
init() {
|
||||
this.settings = { ...this.$store.state.settings.settings }
|
||||
this.settings = { ...this.$store.state.user.settings }
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
for (const key in settings) {
|
||||
this.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 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 p-4">
|
||||
<div class="absolute -top-16 left-4">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</div>
|
||||
@@ -14,7 +14,7 @@
|
||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
</div>
|
||||
|
||||
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export default {
|
||||
return 'Logo.png'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
@@ -48,6 +49,9 @@ export default {
|
||||
book() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
@@ -63,6 +67,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
audioPlayerMounted() {
|
||||
this.audioPlayerReady = true
|
||||
if (this.stream) {
|
||||
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
|
||||
this.openStream()
|
||||
@@ -92,7 +97,7 @@ export default {
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
@@ -102,14 +107,14 @@ export default {
|
||||
streamOpen(stream) {
|
||||
this.stream = stream
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log('[STREAM-CONTAINER] streamOpen', stream)
|
||||
console.log('[StreamContainer] streamOpen', stream)
|
||||
this.openStream()
|
||||
} else {
|
||||
} else if (this.audioPlayerReady) {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
if (this.stream && this.stream.id === streamId) {
|
||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
||||
this.terminateStream()
|
||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||
this.stream = null
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
||||
<div class="flex-grow px-2 searchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: calc(40px * 1.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,45 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
|
||||
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<!-- New Book Flag -->
|
||||
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
|
||||
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
|
||||
<p class="text-center text-sm">New</p>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons text-5xl">play_circle_filled</span>
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
<div v-show="!isSelectionMode" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate || userCanDelete" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" style="font-size: 16px">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
|
||||
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -36,6 +52,10 @@ export default {
|
||||
userProgress: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -44,27 +64,73 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isNew() {
|
||||
return this.tags.includes('new')
|
||||
},
|
||||
tags() {
|
||||
return this.audiobook.tags || []
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
selected() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
width() {
|
||||
return 120
|
||||
},
|
||||
height() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
title() {
|
||||
return this.book.title
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
author() {
|
||||
return this.book.author
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL || this.author
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF || this.author
|
||||
},
|
||||
authorFormat() {
|
||||
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
|
||||
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts
|
||||
},
|
||||
@@ -84,9 +150,28 @@ export default {
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
clickError(e) {
|
||||
e.stopPropagation()
|
||||
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||
@@ -97,14 +182,15 @@ export default {
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookCard {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<img ref="cover" :src="cover" @error="imageError" class="w-full h-full object-cover" />
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
@@ -26,6 +32,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -33,7 +40,13 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -50,6 +63,7 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorCleaned() {
|
||||
@@ -58,8 +72,22 @@ export default {
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.cover || this.cover === this.placeholderUrl) return ''
|
||||
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
|
||||
try {
|
||||
var url = new URL(this.cover, document.baseURI)
|
||||
return url.href
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return ''
|
||||
}
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || '/book_placeholder.jpg'
|
||||
return this.book.cover || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
@@ -81,6 +109,31 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
this.$refs.coverBg.style.backgroundSize = 'cover'
|
||||
this.$refs.coverBg.style.backgroundPosition = 'center'
|
||||
this.$refs.coverBg.style.opacity = 0.25
|
||||
this.$refs.coverBg.style.filter = 'blur(1px)'
|
||||
}
|
||||
},
|
||||
hideCoverBg() {},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
@@ -39,9 +42,9 @@
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -73,6 +76,16 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Authors',
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -101,31 +114,38 @@ export default {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
if (parts.length > 1) {
|
||||
return this.snakeToNormal(parts[1])
|
||||
return this.$decode(parts[1])
|
||||
}
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
return this.$store.getters['audiobooks/getGenresUsed']
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
authors() {
|
||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||
},
|
||||
sublistItems() {
|
||||
return this[this.sublist] || []
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
return {
|
||||
text: item,
|
||||
value: this.$encode(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
clearSelected() {
|
||||
this.selected = 'all'
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
},
|
||||
clickOutside() {
|
||||
if (!this.selectedItemSublist) this.sublist = null
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
</li>
|
||||
<li v-else-if="!items.length" class="py-2 px-2">
|
||||
<p>No Results</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
|
||||
<template v-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
isFocused: false,
|
||||
focusTimeout: null,
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
items: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focussed() {
|
||||
this.isFocused = true
|
||||
this.showMenu = true
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
clearTimeout(this.focusTimeout)
|
||||
this.focusTimeout = setTimeout(() => {
|
||||
this.showMenu = false
|
||||
}, 200)
|
||||
},
|
||||
async runSearch(value) {
|
||||
this.lastSearch = value
|
||||
if (!this.lastSearch) {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.isFetching = false
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
data: res,
|
||||
type: 'audiobook'
|
||||
}
|
||||
})
|
||||
},
|
||||
inputUpdate(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
if (!val) {
|
||||
this.lastSearch = ''
|
||||
this.isTyping = false
|
||||
return
|
||||
}
|
||||
this.isTyping = true
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.isTyping = false
|
||||
this.runSearch(val)
|
||||
}, 1000)
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.type === 'audiobook') {
|
||||
this.$router.push(`/audiobook/${option.data.id}`)
|
||||
}
|
||||
},
|
||||
clickClear() {
|
||||
if (this.search) {
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span> -->
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
@@ -42,13 +37,21 @@ export default {
|
||||
value: 'book.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
value: 'book.author'
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Volume #',
|
||||
value: 'book.volumeNumber'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
value: 'duration'
|
||||
@@ -78,7 +81,8 @@ export default {
|
||||
}
|
||||
},
|
||||
selectedText() {
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
|
||||
@@ -8,12 +8,24 @@
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full no-scroll flex">
|
||||
<template v-for="(rate, index) in rates">
|
||||
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm">⨯</span></p>
|
||||
<div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 h-full w-7 border-r border-black-300 bg-black-300 rounded-l-lg flex items-center justify-center cursor-pointer" :class="rateIndex === 0 ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="leftArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
|
||||
</div>
|
||||
<div class="overflow-hidden relative" style="width: 220px">
|
||||
<div class="flex items-center h-full absolute top-0 left-0 transition-transform duration-100" :style="{ transform: `translateX(${xPos}px)` }">
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border-r" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 h-full w-7 bg-black-300 rounded-r-lg flex items-center justify-center cursor-pointer" :class="rateIndex === rates.length - numVisible ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="rightArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +41,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
showMenu: false,
|
||||
rateIndex: 1,
|
||||
numVisible: 5
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -42,7 +56,10 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
|
||||
return [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
},
|
||||
xPos() {
|
||||
return -1 * this.rateIndex * 44
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -55,6 +72,12 @@ export default {
|
||||
this.playbackRate = newPlaybackRate
|
||||
if (hasChanged) this.$emit('change', newPlaybackRate)
|
||||
this.showMenu = false
|
||||
},
|
||||
leftArrowClick() {
|
||||
this.rateIndex = Math.max(0, this.rateIndex - 4)
|
||||
},
|
||||
rightArrowClick() {
|
||||
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="px-2">
|
||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 mt-4">
|
||||
<p class="text-lg mb-2">Permissions</p>
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div class="w-1/2">
|
||||
<p>Can Download</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div class="w-1/2">
|
||||
<p>Can Update</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div class="w-1/2">
|
||||
<p>Can Delete</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
account: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin']
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newUser.username) {
|
||||
this.$toast.error('Enter a username')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateAccount()
|
||||
} else {
|
||||
this.submitUpdateAccount()
|
||||
}
|
||||
},
|
||||
submitUpdateAccount() {
|
||||
var account = { ...this.newUser }
|
||||
if (!account.password || account.type === 'root') {
|
||||
delete account.password
|
||||
}
|
||||
if (account.type === 'root' && !account.isActive) return
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/user/${this.account.id}`, account)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||
} else {
|
||||
this.$toast.success('Account updated')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update account', error)
|
||||
this.processing = false
|
||||
this.$toast.error('Failed to update account')
|
||||
})
|
||||
},
|
||||
submitCreateAccount() {
|
||||
if (!this.newUser.password) {
|
||||
this.$toast.error('Must have a password, only root user can have an empty password')
|
||||
return
|
||||
}
|
||||
|
||||
var account = { ...this.newUser }
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/user', account)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to create account: ${data.error}`)
|
||||
} else {
|
||||
this.$toast.success('New account created')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create account', error)
|
||||
this.processing = false
|
||||
this.$toast.error('Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin'
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions }
|
||||
}
|
||||
} else {
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
type: 'user',
|
||||
isActive: true,
|
||||
permissions: {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="500" :height="'unset'">
|
||||
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChapter(chap) {
|
||||
this.$emit('select', chap)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
|
||||
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 w-full flex">
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<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" />
|
||||
</keep-alive>
|
||||
@@ -23,20 +22,63 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
selectedTab: 'details',
|
||||
processing: false,
|
||||
audiobook: null
|
||||
audiobook: null,
|
||||
fetchOnShow: false,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-edit-tabs-details'
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
component: 'modals-edit-tabs-tracks'
|
||||
},
|
||||
{
|
||||
id: 'chapters',
|
||||
title: 'Chapters',
|
||||
component: 'modals-edit-tabs-chapters'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
|
||||
var availableTabIds = this.availableTabs.map((tab) => tab.id)
|
||||
if (!availableTabIds.length) {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
||||
if (this.fetchOnShow) this.fetchFull()
|
||||
return
|
||||
}
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.init()
|
||||
} else {
|
||||
this.$store.commit('audiobooks/removeListener', 'edit-modal')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,12 +92,35 @@ export default {
|
||||
this.$store.commit('setShowEditModal', val)
|
||||
}
|
||||
},
|
||||
selectedTab: {
|
||||
get() {
|
||||
return this.$store.state.editModalTab
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setEditModalTab', val)
|
||||
}
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||
return false
|
||||
})
|
||||
},
|
||||
height() {
|
||||
var maxHeightAllowed = window.innerHeight - 150
|
||||
return Math.min(maxHeightAllowed, 650)
|
||||
},
|
||||
tabName() {
|
||||
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
|
||||
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
|
||||
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
|
||||
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
|
||||
return ''
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook || {}
|
||||
@@ -75,7 +140,10 @@ export default {
|
||||
this.selectedTab = tab
|
||||
},
|
||||
audiobookUpdated() {
|
||||
this.fetchFull()
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
else {
|
||||
this.fetchFull()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-30 opacity-0">
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
@@ -31,6 +31,10 @@ export default {
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: 'unset'
|
||||
},
|
||||
contentMarginTop: {
|
||||
type: Number,
|
||||
default: 50
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<template v-for="chapter in chapters">
|
||||
<tr :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chapters: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
init() {
|
||||
this.chapters = this.audiobook.chapters || []
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<div class="flex">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<span class="material-icons">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center">
|
||||
@@ -10,6 +19,24 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image(s)</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover.localPath" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
|
||||
<div class="flex-grow px-1">
|
||||
@@ -23,11 +50,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">No Covers Found</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -37,6 +67,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
@@ -51,14 +83,17 @@ export default {
|
||||
searchAuthor: null,
|
||||
imageUrl: null,
|
||||
coversFound: [],
|
||||
hasSearched: false
|
||||
hasSearched: false,
|
||||
showLocalCovers: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,10 +108,23 @@ export default {
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
_file.localPath = Path.join('local', _file.path)
|
||||
return _file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
@@ -85,10 +133,22 @@ export default {
|
||||
this.searchTitle = this.book.title || ''
|
||||
this.searchAuthor = this.book.author || ''
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.book.cover) {
|
||||
this.imageUrl = ''
|
||||
return
|
||||
}
|
||||
this.updateCover('')
|
||||
},
|
||||
submitForm() {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
if (cover === this.book.cover) {
|
||||
console.warn('Cover has not changed..', cover)
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {
|
||||
@@ -101,13 +161,12 @@ export default {
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=best&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
|
||||
@@ -1,36 +1,63 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
|
||||
<div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
|
||||
<div class="w-full flex items-center">
|
||||
<p>
|
||||
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm">
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
<div class="w-full h-full relative">
|
||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
||||
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
||||
<!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
|
||||
<div class="w-full flex items-center">
|
||||
<p>
|
||||
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
|
||||
</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div> -->
|
||||
|
||||
<ui-text-input-with-label v-model="details.title" label="Title" />
|
||||
|
||||
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="details.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
|
||||
|
||||
<div class="flex py-4">
|
||||
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
<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-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -49,14 +76,18 @@ export default {
|
||||
return {
|
||||
details: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
author: null,
|
||||
narrarator: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
genres: []
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
genres: ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
isScrollable: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -82,44 +113,52 @@ export default {
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.$store.getters['getUserAudiobook'](this.audiobookId)
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userProgress() {
|
||||
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addGenre(genre) {
|
||||
this.genres.push({
|
||||
text: genre,
|
||||
value: genre
|
||||
})
|
||||
},
|
||||
async submitForm() {
|
||||
console.log('Submit form', this.details)
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: this.details
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.details.title = this.book.title
|
||||
this.details.subtitle = this.book.subtitle
|
||||
this.details.description = this.book.description
|
||||
this.details.author = this.book.author
|
||||
this.details.narrarator = this.book.narrarator
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
@@ -138,7 +177,7 @@ export default {
|
||||
}
|
||||
},
|
||||
deleteAudiobook() {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?`)) {
|
||||
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/audiobook/${this.audiobookId}`)
|
||||
@@ -153,7 +192,41 @@ export default {
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.formWrapper) {
|
||||
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
|
||||
this.isScrollable = true
|
||||
} else {
|
||||
this.isScrollable = false
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
setResizeObserver() {
|
||||
try {
|
||||
this.$nextTick(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
this.checkIsScrollable()
|
||||
})
|
||||
resizeObserver.observe(this.$refs.formWrapper)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to set resize observer')
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// this.init()
|
||||
this.setResizeObserver()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-form-wrapper {
|
||||
height: calc(100% - 70px);
|
||||
max-height: calc(100% - 70px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
|
||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
|
||||
<p v-else>Zip 1 File</p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
|
||||
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
|
||||
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
|
||||
</div>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
singleDownloadStatus(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
singleAudioDownload() {
|
||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
||||
},
|
||||
singleDownloadStatus() {
|
||||
return this.singleAudioDownload ? this.singleAudioDownload.status : false
|
||||
},
|
||||
zipDownload() {
|
||||
return this.downloads.find((d) => d.type === 'zip')
|
||||
},
|
||||
zipDownloadStatus() {
|
||||
return this.zipDownload ? this.zipDownload.status : false
|
||||
},
|
||||
isSingleTrack() {
|
||||
if (!this.audiobook.tracks) return false
|
||||
return this.audiobook.tracks.length === 1
|
||||
},
|
||||
singleTrackPath() {
|
||||
if (!this.isSingleTrack) return null
|
||||
return this.audiobook.tracks[0].path
|
||||
},
|
||||
audioFiles() {
|
||||
return this.audiobook ? this.audiobook.audioFiles || [] : []
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
totalFiles() {
|
||||
return this.audioFiles.length + this.otherFiles.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startZipDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'zip'
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
startSingleAudioDownload() {
|
||||
// console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'singleAudio',
|
||||
includeMetadata: true,
|
||||
includeCover: true
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
downloadWithProgress(download) {
|
||||
var downloadId = download.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = download.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<div class="w-full h-full overflow-hidden px-4 py-6">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-72 px-1">
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>No Results</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||
@@ -37,11 +37,13 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
audiobookId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'best',
|
||||
searchResults: []
|
||||
searchResults: [],
|
||||
hasSearched: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -64,7 +66,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
@@ -90,15 +92,22 @@ export default {
|
||||
})
|
||||
this.searchResults = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.author || ''
|
||||
this.runSearch()
|
||||
},
|
||||
async selectMatch(match) {
|
||||
this.isProcessing = true
|
||||
@@ -120,7 +129,6 @@ export default {
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="flex mb-4">
|
||||
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
||||
<ui-btn color="primary">Edit Track Order</ui-btn>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -26,6 +27,9 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="font-mono text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
@@ -54,12 +58,18 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.audioFiles = this.audiobook.audioFiles
|
||||
this.tracks = this.audiobook.tracks
|
||||
console.log('INIT', this.audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p class="pr-4">Other Audio Files</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
@@ -56,7 +56,11 @@ export default {
|
||||
showTracks: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<p class="pr-4">Audio Tracks</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||
</nuxt-link>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
@@ -19,6 +19,7 @@
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -34,6 +35,9 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
@@ -56,7 +60,14 @@ export default {
|
||||
showTracks: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +22,9 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
small: Boolean
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -24,6 +32,7 @@ export default {
|
||||
computed: {
|
||||
classList() {
|
||||
var list = []
|
||||
if (this.loading) list.push('text-opacity-0')
|
||||
list.push('text-white')
|
||||
list.push(`bg-${this.color}`)
|
||||
if (this.small) {
|
||||
@@ -50,7 +59,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.btn::before {
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
@@ -61,7 +70,10 @@ button.btn::before {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.btn:hover::before {
|
||||
.btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||
<span class="material-icons icon-text">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
this.$emit('click')
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.icon-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.icon-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
|
||||
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
disabled: Boolean,
|
||||
label: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.textInput = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
inputBlur() {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
var newValue = this.input === item ? null : item
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = this.$cleanString(newValue) || null
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="w-40">
|
||||
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
|
||||
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
|
||||
<div class="loader-dots block relative w-20 h-5 mt-2">
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
|
||||
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -13,7 +18,7 @@
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
@@ -47,7 +52,6 @@ export default {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
@@ -71,38 +75,14 @@ export default {
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var normie = this.snakeToNormal(i)
|
||||
var iValue = String(normie).toLowerCase()
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
},
|
||||
normalToSnake(normie) {
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
},
|
||||
setMatchingItems() {
|
||||
if (!this.textInput) {
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.isTyping = true
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
@@ -156,8 +136,10 @@ export default {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, itemValue) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
@@ -169,6 +151,9 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.showMenu) {
|
||||
@@ -176,10 +161,15 @@ export default {
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
var kebabItem = this.normalToSnake(item)
|
||||
this.selected.push(kebabItem)
|
||||
this.$emit('addOption', kebabItem)
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
@@ -191,12 +181,11 @@ export default {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.toLowerCase().trim()
|
||||
var cleanedKebab = this.normalToSnake(cleaned)
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned || cleanedKebab === i
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(matchesItem.value)
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||
<div class="w-5 h-5 text-white relative">
|
||||
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
<!-- <svg v-if="!isRead" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 482.204 482.204" xml:space="preserve" fill="currentColor">
|
||||
<path
|
||||
d="M83.127,344.477c54.602,1.063,101.919,9.228,136.837,23.613c0.596,0.244,1.227,0.366,1.852,0.366
|
||||
c0.95,0,1.895-0.279,2.706-0.822c1.349-0.902,2.158-2.418,2.158-4.041l0.019-261.017c0-1.992-1.215-3.783-3.066-4.519
|
||||
L85.019,42.899c-1.496-0.596-3.193-0.411-4.527,0.494c-1.334,0.906-2.133,2.413-2.133,4.025v292.197
|
||||
C78.359,342.264,80.479,344.425,83.127,344.477z"
|
||||
/>
|
||||
<path
|
||||
d="M480.244,89.256c-1.231-0.917-2.824-1.198-4.297-0.759l-49.025,14.657
|
||||
c-2.06,0.616-3.471,2.51-3.471,4.659v252.151c0,0,0.218,3.978-3.97,3.978c-4.796,0-7.946,0-7.946,0
|
||||
c-39.549,0-113.045,4.105-160.93,31.6l-9.504,5.442l-9.503-5.442c-47.886-27.494-121.381-31.6-160.93-31.6c0,0-8.099,0-10.142,0
|
||||
c-1.891,0-1.775-2.272-1.775-2.271V107.813c0-2.149-1.411-4.043-3.47-4.659L6.256,88.497c-1.473-0.439-3.066-0.158-4.298,0.759
|
||||
S0,91.619,0,93.155v305.069c0,1.372,0.581,2.681,1.597,3.604c1.017,0.921,2.375,1.372,3.741,1.236
|
||||
c14.571-1.429,37.351-3.131,63.124-3.131c56.606,0,102.097,8.266,131.576,23.913c4.331,2.272,29.441,15.803,41.065,15.803
|
||||
c11.624,0,36.733-13.53,41.063-15.803c29.48-15.647,74.971-23.913,131.577-23.913c25.771,0,48.553,1.702,63.123,3.131
|
||||
c1.367,0.136,2.725-0.315,3.742-1.236c1.016-0.923,1.596-2.231,1.596-3.604V93.155C482.203,91.619,481.476,90.173,480.244,89.256z
|
||||
"
|
||||
/>
|
||||
<path
|
||||
d="M257.679,367.634c0.812,0.543,1.757,0.822,2.706,0.822c0.626,0,1.256-0.122,1.853-0.366
|
||||
c34.917-14.386,82.235-22.551,136.837-23.613c2.648-0.052,4.769-2.213,4.769-4.861V47.418c0-1.613-0.799-3.12-2.133-4.025
|
||||
c-1.334-0.904-3.031-1.09-4.528-0.494L258.569,98.057c-1.851,0.736-3.065,2.527-3.065,4.519l0.019,261.017
|
||||
C255.521,365.216,256.331,366.732,257.679,367.634z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M19 2H6c-1.206 0-3 .799-3 3v14c0 2.201 1.794 3 3 3h15v-2H6.012C5.55 19.988 5 19.806 5 19c0-.101.009-.191.024-.273.112-.576.584-.717.988-.727H21V4a2 2 0 0 0-2-2zm0 9-2-1-2 1V4h4v7z" /></svg> -->
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isRead: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
this.$emit('click')
|
||||
e.stopPropagation()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.icon-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.icon-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -12,8 +12,15 @@ export default {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
transparent: Boolean,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
paddingY: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
paddingX: {
|
||||
type: Number,
|
||||
default: 3
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -26,11 +33,26 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
classList() {
|
||||
var _list = []
|
||||
_list.push(`px-${this.paddingX}`)
|
||||
_list.push(`py-${this.paddingY}`)
|
||||
return _list.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focused() {
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
{{ 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" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,6 +12,7 @@ export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
note: String,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
onColor: {
|
||||
type: String,
|
||||
default: 'success'
|
||||
},
|
||||
offColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
computed: {
|
||||
toggleValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
className() {
|
||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||
},
|
||||
switchClassName() {
|
||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
||||
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickToggle() {
|
||||
if (this.disabled) return
|
||||
this.toggleValue = !this.toggleValue
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,6 +10,10 @@ export default {
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -18,18 +22,38 @@ export default {
|
||||
isShowing: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.updateText()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateText() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.innerHTML = this.text
|
||||
}
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
var top = boxChow.top
|
||||
var left = boxChow.left + boxChow.width + 4
|
||||
|
||||
var top = 0
|
||||
var left = 0
|
||||
if (this.direction === 'right') {
|
||||
top = boxChow.top
|
||||
left = boxChow.left + boxChow.width + 4
|
||||
} else if (this.direction === 'bottom') {
|
||||
top = boxChow.top + boxChow.height + 4
|
||||
left = boxChow.left
|
||||
} else if (this.direction === 'top') {
|
||||
top = boxChow.top - 24
|
||||
left = boxChow.left
|
||||
}
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.innerText = this.text
|
||||
tooltip.innerHTML = this.text
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
showTooltip() {
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
<template>
|
||||
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
|
||||
<p class="text-lg font-sans" v-html="text" />
|
||||
</div>
|
||||
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
|
||||
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
hasCanceled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isScanning(newVal) {
|
||||
if (newVal) {
|
||||
this.hasCanceled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
|
||||
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
},
|
||||
isScanning() {
|
||||
return this.isScanningFiles || this.isScanningCovers
|
||||
},
|
||||
isScanningFiles() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
scanProgressKey() {
|
||||
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
|
||||
},
|
||||
scanProgress() {
|
||||
return this.$store.state.scanProgress
|
||||
return this.$store.state[this.scanProgressKey]
|
||||
},
|
||||
scanPercent() {
|
||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
||||
@@ -31,7 +53,12 @@ export default {
|
||||
return this.scanProgress ? this.scanProgress.total : 0
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
cancelScan() {
|
||||
this.hasCanceled = true
|
||||
this.$root.socket.emit('cancel_scan')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
data() {
|
||||
return {
|
||||
socket: null
|
||||
@@ -20,17 +21,20 @@ export default {
|
||||
if (this.$store.state.showEditModal) {
|
||||
this.$store.commit('setShowEditModal', false)
|
||||
}
|
||||
if (this.$store.state.selectedAudiobooks) {
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connect() {
|
||||
console.log('[SOCKET] Connected')
|
||||
var token = this.$store.getters.getToken
|
||||
var token = this.$store.getters['user/getToken']
|
||||
this.socket.emit('auth', token)
|
||||
},
|
||||
connectError() {},
|
||||
@@ -49,7 +53,11 @@ export default {
|
||||
}
|
||||
}
|
||||
if (payload.user) {
|
||||
this.$store.commit('setUser', payload.user)
|
||||
this.$store.commit('user/setUser', payload.user)
|
||||
this.$store.commit('user/setSettings', payload.user.settings)
|
||||
}
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
@@ -81,21 +89,102 @@ export default {
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
},
|
||||
scanComplete() {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
this.$toast.success('Scan Finished')
|
||||
scanComplete({ scanType, results }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', false)
|
||||
if (results) {
|
||||
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
if (results) {
|
||||
var scanResultMsgs = []
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||
}
|
||||
}
|
||||
},
|
||||
scanStart() {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
scanStart(scanType) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', true)
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
}
|
||||
},
|
||||
scanProgress(progress) {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
scanProgress({ scanType, progress }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setCoverScanProgress', progress)
|
||||
} else {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.id === user.id) {
|
||||
this.$store.commit('setUser', user)
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setSettings', user.settings)
|
||||
}
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
console.log('Downlaod ready toast click', download)
|
||||
// if (!download || !download.audiobookId) {
|
||||
// return console.error('Invalid download object', download)
|
||||
// }
|
||||
// var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||
// if (!audiobook) {
|
||||
// return console.error('Audiobook not found for download', download)
|
||||
// }
|
||||
// this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
||||
},
|
||||
downloadStarted(download) {
|
||||
download.status = this.$constants.DownloadStatus.PENDING
|
||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: this.downloadToastClick })
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadReady(download) {
|
||||
download.status = this.$constants.DownloadStatus.READY
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: this.downloadToastClick } }, true)
|
||||
} else {
|
||||
this.$toast.success(`Download "${download.filename}" is ready!`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadFailed(download) {
|
||||
download.status = this.$constants.DownloadStatus.FAILED
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
|
||||
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
||||
} else {
|
||||
console.warn('Download failed no existing download', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
||||
}
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadKilled(download) {
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
||||
} else {
|
||||
console.warn('Download killed no existing download found', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" was terminated`)
|
||||
}
|
||||
this.$store.commit('downloads/removeDownload', download)
|
||||
},
|
||||
downloadExpired(download) {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -136,21 +225,58 @@ export default {
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
|
||||
// Download Listeners
|
||||
this.socket.on('download_started', this.downloadStarted)
|
||||
this.socket.on('download_ready', this.downloadReady)
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
},
|
||||
checkVersion() {
|
||||
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => {
|
||||
console.log('GOT DATA', data)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
if (!this.$store.state.user) {
|
||||
this.$router.replace(`/login?redirect=${this.$route.path}`)
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
var latestVersion = versionData.latestVersion
|
||||
|
||||
if (!ignoreVersion || ignoreVersion !== latestVersion) {
|
||||
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
|
||||
position: 'top-center',
|
||||
toastClassName: 'cursor-pointer',
|
||||
bodyClassName: 'custom-class-1',
|
||||
timeout: 20000,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
hideProgressBar: false,
|
||||
onClick: () => {
|
||||
window.open(versionData.githubTagUrl, '_blank')
|
||||
},
|
||||
onClose: () => {
|
||||
localStorage.setItem('ignoreVersion', versionData.latestVersion)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
this.checkVersion()
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function ({ store, redirect, route }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ module.exports = {
|
||||
|
||||
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||
plugins: [
|
||||
'@/plugins/constants.js',
|
||||
'@/plugins/init.client.js',
|
||||
'@/plugins/axios.js',
|
||||
'@/plugins/toast.js'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "0.9.6-beta",
|
||||
"version": "1.1.9",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="w-full h-full p-8">
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<h1 class="text-2xl">Account</h1>
|
||||
|
||||
<div class="my-4">
|
||||
<div class="flex -mx-2">
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="username" label="Username" />
|
||||
</div>
|
||||
<div class="w-1/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="usertype" label="Account Type" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-primary my-4" />
|
||||
|
||||
<p class="mb-4 text-lg">Change Password</p>
|
||||
<form @submit.prevent="submitChangePassword">
|
||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
||||
<div class="flex items-center py-2">
|
||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="py-4 mt-8 flex">
|
||||
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>Logout</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
password: null,
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
changingPassword: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user || null
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
usertype() {
|
||||
return this.user.type
|
||||
},
|
||||
isRoot() {
|
||||
return this.usertype === 'root'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
if (localStorage.getItem('token')) {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
this.$router.push('/login')
|
||||
},
|
||||
resetForm() {
|
||||
this.password = null
|
||||
this.newPassword = null
|
||||
this.confirmPassword = null
|
||||
},
|
||||
submitChangePassword() {
|
||||
if (this.newPassword !== this.confirmPassword) {
|
||||
return this.$toast.error('New password and confirm password do not match')
|
||||
}
|
||||
if (this.password === this.newPassword) {
|
||||
return this.$toast.error('Password and New Password cannot be the same')
|
||||
}
|
||||
this.changingPassword = true
|
||||
this.$axios
|
||||
.$patch('/api/user/password', {
|
||||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success('Password Changed Successfully')
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || 'Unknown Error')
|
||||
}
|
||||
this.changingPassword = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error('Api call failed')
|
||||
this.changingPassword = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -19,16 +19,15 @@
|
||||
<div class="font-mono w-20 text-center">Duration</div>
|
||||
<div class="font-mono text-center w-20">Status</div>
|
||||
<div class="font-mono w-56">Notes</div>
|
||||
<div class="font-book w-40">Include in Tracklist</div>
|
||||
</div>
|
||||
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(audio, index) in files" :key="audio.path" class="w-full list-group-item item flex items-center">
|
||||
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
|
||||
<div class="font-book text-center px-4 py-1 w-12">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="font-book text-center px-4 w-12">
|
||||
{{ audio.index }}
|
||||
{{ audio.include ? index - numExcluded + 1 : -1 }}
|
||||
</div>
|
||||
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
|
||||
<div class="font-book text-center px-2 w-32">
|
||||
{{ audio.trackNumFromFilename }}
|
||||
</div>
|
||||
@@ -51,6 +50,9 @@
|
||||
<div class="font-sans text-xs font-normal w-56">
|
||||
{{ audio.error }}
|
||||
</div>
|
||||
<div class="font-sans text-xs font-normal w-40 flex justify-center">
|
||||
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
|
||||
</div>
|
||||
</li>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
@@ -66,9 +68,12 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
@@ -77,10 +82,9 @@ export default {
|
||||
console.error('No audiobook...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
let index = 0
|
||||
return {
|
||||
audiobook,
|
||||
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : []
|
||||
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -98,6 +102,13 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
numExcluded() {
|
||||
var count = 0
|
||||
this.files.forEach((file) => {
|
||||
if (!file.include) count++
|
||||
})
|
||||
return count
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -164,15 +175,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
includeToggled(audio) {
|
||||
var new_index = 0
|
||||
if (audio.include) {
|
||||
new_index = this.numExcluded
|
||||
}
|
||||
var old_index = this.files.findIndex((f) => f.ino === audio.ino)
|
||||
if (new_index >= this.files.length) {
|
||||
var k = new_index - this.files.length + 1
|
||||
while (k--) {
|
||||
this.files.push(undefined)
|
||||
}
|
||||
}
|
||||
this.files.splice(new_index, 0, this.files.splice(old_index, 1)[0])
|
||||
},
|
||||
saveTracklist() {
|
||||
console.log('Tracklist', this.files)
|
||||
var orderedFileData = this.files.map((file) => {
|
||||
return {
|
||||
index: file.index,
|
||||
filename: file.filename,
|
||||
ino: file.ino,
|
||||
exclude: !file.include
|
||||
}
|
||||
})
|
||||
|
||||
this.saving = true
|
||||
this.$axios
|
||||
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files })
|
||||
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData })
|
||||
.then((data) => {
|
||||
console.log('Finished patching files', data)
|
||||
this.saving = false
|
||||
// this.$router.go()
|
||||
this.$toast.success('Tracks Updated')
|
||||
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||
})
|
||||
@@ -207,16 +239,26 @@ export default {
|
||||
.list-group {
|
||||
min-height: 30px;
|
||||
}
|
||||
.list-group-item {
|
||||
.list-group-item:not(.exclude) {
|
||||
cursor: n-resize;
|
||||
}
|
||||
.list-group-item:not(.ghost):hover {
|
||||
.list-group-item.exclude {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.list-group-item:not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost) {
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item:nth-child(even):not(.ghost):hover {
|
||||
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.list-group-item.exclude:not(.ghost) {
|
||||
background-color: rgba(255, 0, 0, 0.25);
|
||||
}
|
||||
.list-group-item.exclude:not(.ghost):hover {
|
||||
background-color: rgba(223, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,61 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full h-full overflow-y-auto p-8">
|
||||
<div class="flex max-w-6xl mx-auto">
|
||||
<div class="w-52" style="min-width: 208px">
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" :width="208" />
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div>
|
||||
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow px-10">
|
||||
<div class="flex">
|
||||
<h1 class="text-2xl">{{ title }}</h1>
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm my-1">
|
||||
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
|
||||
</p>
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
|
||||
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
Play
|
||||
</ui-btn>
|
||||
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>
|
||||
|
||||
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="userCanDownload" text="Download" direction="top">
|
||||
<ui-icon-btn icon="download" class="mx-0.5" @click="downloadClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<p class="text-sm text-gray-100">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||
<p class="text-sm mb-2">
|
||||
@@ -44,7 +68,9 @@
|
||||
<p class="text-sm mb-2">
|
||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
|
||||
@@ -61,7 +87,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
@@ -78,10 +104,23 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
resettingProgress: false
|
||||
isRead: false,
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
userIsRead: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.isRead = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -128,6 +167,27 @@ export default {
|
||||
author() {
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF
|
||||
},
|
||||
authorTooltipText() {
|
||||
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
|
||||
return txt.join('<br>')
|
||||
},
|
||||
series() {
|
||||
return this.book.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
durationPretty() {
|
||||
return this.audiobook.durationPretty
|
||||
},
|
||||
@@ -155,10 +215,10 @@ export default {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
description() {
|
||||
return this.book.description || 'No Description'
|
||||
return this.book.description || ''
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.userAudiobooks[this.audiobookId] || null
|
||||
@@ -166,6 +226,9 @@ export default {
|
||||
userCurrentTime() {
|
||||
return this.userAudiobook ? this.userAudiobook.currentTime : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userAudiobook ? !!this.userAudiobook.isRead : false
|
||||
},
|
||||
userTimeRemaining() {
|
||||
return this.duration - this.userCurrentTime
|
||||
},
|
||||
@@ -175,11 +238,49 @@ export default {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isStreaming() {
|
||||
streaming() {
|
||||
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
}
|
||||
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'}`)
|
||||
})
|
||||
},
|
||||
openRssFeed() {
|
||||
this.$axios
|
||||
.$post('/api/feed', { audiobookId: this.audiobook.id })
|
||||
.then((res) => {
|
||||
console.log('Feed open', res)
|
||||
this.$toast.success('RSS Feed Open')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to open feed')
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.audiobook)
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
@@ -223,6 +324,9 @@ export default {
|
||||
this.resettingProgress = false
|
||||
})
|
||||
}
|
||||
},
|
||||
downloadClick() {
|
||||
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<template v-for="audiobook in audiobookCopies">
|
||||
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
|
||||
<div class="w-32">
|
||||
<cards-book-cover :audiobook="audiobook.originalAudiobook" :width="120" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4">
|
||||
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
|
||||
|
||||
<ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="audiobook.book.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click="saveClick">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.state.selectedAudiobooks.length) {
|
||||
return redirect('/')
|
||||
}
|
||||
var audiobooks = store.state.audiobooks.audiobooks.filter((ab) => store.state.selectedAudiobooks.includes(ab.id))
|
||||
return {
|
||||
audiobooks
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
seriesItems() {
|
||||
return [...this.series, ...this.newSeriesItems]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
newSeriesItem(item) {
|
||||
if (!item) return
|
||||
this.newSeriesItems.push(item)
|
||||
},
|
||||
seriesChanged() {
|
||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||
return this.audiobookCopies.find((ab) => ab.book.series === item)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.audiobookCopies = this.audiobooks.map((ab) => {
|
||||
var copy = { ...ab }
|
||||
copy.tags = [...ab.tags]
|
||||
copy.book = { ...ab.book }
|
||||
copy.book.genres = [...ab.book.genres]
|
||||
copy.originalAudiobook = ab
|
||||
return copy
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {
|
||||
this.isScrollable = true
|
||||
}
|
||||
})
|
||||
},
|
||||
saveClick() {
|
||||
this.isProcessing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/audiobooks/update', this.audiobookCopies)
|
||||
.then((data) => {
|
||||
this.isProcessing = false
|
||||
if (data.updates) {
|
||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||
this.$router.replace('/')
|
||||
} else {
|
||||
this.$toast.warning('No updates were necessary')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to batch update', error)
|
||||
this.$toast.error('Failed to batch update')
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,67 @@
|
||||
<template>
|
||||
<div class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl mb-2">Config</h1>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="p-4 text-center h-20">
|
||||
<p>Nothing much here yet...</p>
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-2xl">Users</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
|
||||
</div>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="flex items-center py-4 mb-8">
|
||||
<div class="p-4 text-center">
|
||||
<table id="accounts" class="mb-8">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<td>
|
||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
</td>
|
||||
<td>{{ user.type }}</td>
|
||||
<td class="text-sm font-mono">
|
||||
{{ new Date(user.createdAt).toISOString() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="w-full flex justify-center">
|
||||
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
|
||||
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="py-4 mb-8">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="scan">Scan</ui-btn>
|
||||
<div class="flex items-start py-2">
|
||||
<div class="py-2">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
||||
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<p class="font-mono">v{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -26,24 +75,202 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isResettingAudiobooks: false,
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false,
|
||||
newServerSettings: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
parseSubtitleTooltip() {
|
||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isScanning() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateScannerParseSubtitle(val) {
|
||||
var payload = {
|
||||
scannerParseSubtitle: val
|
||||
}
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
})
|
||||
},
|
||||
setDeveloperMode() {
|
||||
var value = !this.$store.state.developerMode
|
||||
this.$store.commit('setDeveloperMode', value)
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
},
|
||||
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?')) {
|
||||
this.isResettingAudiobooks = true
|
||||
this.$axios
|
||||
.$delete('/api/audiobooks')
|
||||
.then(() => {
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.success('Successfully reset audiobooks')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to reset audiobooks', error)
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
|
||||
})
|
||||
}
|
||||
},
|
||||
clickAddUser() {
|
||||
this.selectedAccount = null
|
||||
this.showAccountModal = true
|
||||
},
|
||||
editUser(user) {
|
||||
this.selectedAccount = user
|
||||
this.showAccountModal = true
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/${user.id}`)
|
||||
.then((data) => {
|
||||
this.isDeletingUser = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('User deleted')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error('Failed to delete user')
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addUpdateUser(user) {
|
||||
if (!this.users) return
|
||||
var index = this.users.findIndex((u) => u.id === user.id)
|
||||
if (index >= 0) {
|
||||
this.users.splice(index, 1, user)
|
||||
} else {
|
||||
this.users.push(user)
|
||||
}
|
||||
},
|
||||
userRemoved(user) {
|
||||
this.users = this.users.filter((u) => u.id !== user.id)
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.on('user_removed', this.userRemoved)
|
||||
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#accounts th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -27,36 +27,31 @@ export default {
|
||||
return {
|
||||
error: null,
|
||||
processing: false,
|
||||
username: 'root',
|
||||
username: '',
|
||||
password: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
user(newVal) {
|
||||
if (newVal) {
|
||||
// if (process.env.NODE_ENV !== 'production') {
|
||||
if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace('/')
|
||||
}
|
||||
|
||||
// } else {
|
||||
// window.location.reload()
|
||||
// }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
// var uri = `${process.env.serverUrl}/auth`
|
||||
|
||||
var payload = {
|
||||
username: this.username,
|
||||
password: this.password || ''
|
||||
@@ -71,7 +66,7 @@ export default {
|
||||
} else if (authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
this.$store.commit('setUser', authRes.user)
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -90,7 +85,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.$store.commit('setUser', res.user)
|
||||
this.$store.commit('user/setUser', res.user)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<main class="container mx-auto h-full max-w-screen-lg p-6">
|
||||
<article class="max-h-full overflow-y-auto relative flex flex-col bg-primary shadow-xl rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="title" label="Title" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="author" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
|
||||
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
|
||||
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
|
||||
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
|
||||
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
|
||||
</header>
|
||||
</section>
|
||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
|
||||
|
||||
<div v-if="validImageFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
|
||||
<div class="flex">
|
||||
<template v-for="file in validImageFiles">
|
||||
<div :key="file.name" class="h-28 w-20 bg-bg">
|
||||
<img :src="file.src" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="validAudioFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
|
||||
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in validAudioFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="invalidFiles.length">
|
||||
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Size</th>
|
||||
</tr>
|
||||
<template v-for="file in invalidFiles">
|
||||
<tr :key="file.name">
|
||||
<td class="font-book">
|
||||
<p class="truncate">{{ file.name }}</p>
|
||||
</td>
|
||||
<td class="font-sm">
|
||||
{{ file.type }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
|
||||
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
|
||||
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
|
||||
</footer>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<ui-loading-indicator text="Uploading..." />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
|
||||
acceptedImageFormats: ['image/*'],
|
||||
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
validImageFiles: [],
|
||||
invalidFiles: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
hasValidAudioFiles() {
|
||||
return this.validAudioFiles.length
|
||||
},
|
||||
directory() {
|
||||
if (!this.author || !this.title) return ''
|
||||
if (this.series) {
|
||||
return Path.join('/audiobooks', this.author, this.series, this.title)
|
||||
} else {
|
||||
return Path.join('/audiobooks', this.author, this.title)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.title = ''
|
||||
this.author = ''
|
||||
this.series = ''
|
||||
this.cancel()
|
||||
},
|
||||
cancel() {
|
||||
this.validAudioFiles = []
|
||||
this.validImageFiles = []
|
||||
this.invalidFiles = []
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
}
|
||||
this.showUploader = true
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
this.filesChanged(_files)
|
||||
}
|
||||
},
|
||||
drop(evt) {
|
||||
console.log('Dropped event', evt)
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
const files = [...evt.dataTransfer.files]
|
||||
this.filesChanged(files)
|
||||
},
|
||||
dragover(evt) {
|
||||
console.log('Dragged over', evt)
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragleave(evt) {
|
||||
console.log('Dragged leave', evt)
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragenter(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
preventDefaults(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
},
|
||||
filesChanged(files) {
|
||||
console.log('FilesChanged', files)
|
||||
this.showUploader = false
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
var ext = Path.extname(file.name)
|
||||
|
||||
if (this.acceptedAudioFormats.includes(ext)) {
|
||||
this.validAudioFiles.push(file)
|
||||
} else if (file.type.startsWith('image/')) {
|
||||
file.src = URL.createObjectURL(file)
|
||||
this.validImageFiles.push(file)
|
||||
} else {
|
||||
this.invalidFiles.push(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
clickSelectAudioFiles() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.click()
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
if (!this.title || !this.author) {
|
||||
this.$toast.error('Must enter a title and author')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
var form = new FormData()
|
||||
form.set('title', this.title)
|
||||
form.set('author', this.author)
|
||||
form.set('series', this.series)
|
||||
|
||||
var index = 0
|
||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||
files.forEach((file) => {
|
||||
form.set(`${index++}`, file)
|
||||
})
|
||||
|
||||
this.$axios
|
||||
.$post('/upload', form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
}
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,16 @@
|
||||
export default function ({ $axios, store }) {
|
||||
$axios.onRequest(config => {
|
||||
console.log('Making request to ' + config.url)
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
var bearerToken = store.state.user ? store.state.user.token : null
|
||||
// console.log('Bearer token', bearerToken)
|
||||
var bearerToken = store.state.user.user ? store.state.user.user.token : null
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
config.url = `/dev${config.url}`
|
||||
console.log('Making request to ' + config.url)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const DownloadStatus = {
|
||||
PENDING: 0,
|
||||
READY: 1,
|
||||
EXPIRED: 2,
|
||||
FAILED: 3
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
DownloadStatus
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
inject('constants', Constants)
|
||||
}
|
||||
@@ -38,29 +38,13 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function loadImageBlob(uri) {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
const c = document.createElement('canvas')
|
||||
const ctx = c.getContext('2d')
|
||||
img.onload = ({ target }) => {
|
||||
c.width = target.naturalWidth
|
||||
c.height = target.naturalHeight
|
||||
ctx.drawImage(target, 0, 0)
|
||||
c.toBlob((b) => resolve(b), 'image/jpeg', 0.75)
|
||||
}
|
||||
img.crossOrigin = ''
|
||||
img.src = uri
|
||||
})
|
||||
}
|
||||
Vue.prototype.$cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
Vue.prototype.$downloadImage = async (uri, name) => {
|
||||
var blob = await loadImageBlob(uri)
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.target = '_blank'
|
||||
a.download = name || 'fotosho-image'
|
||||
a.click()
|
||||
// No longer necessary to replace accented chars, full utf-8 charset is supported
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
return str.trim()
|
||||
}
|
||||
|
||||
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||
@@ -118,3 +102,13 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
.replace(windowsTrailingRe, replacement);
|
||||
return sanitized
|
||||
}
|
||||
|
||||
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
|
||||
Vue.prototype.$encode = encode
|
||||
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||
Vue.prototype.$decode = decode
|
||||
|
||||
export {
|
||||
encode,
|
||||
decode
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import Vue from "vue";
|
||||
import Toast from "vue-toastification";
|
||||
// Import the CSS or use your own!
|
||||
import "vue-toastification/dist/index.css";
|
||||
import Vue from "vue"
|
||||
import Toast from "vue-toastification"
|
||||
import "vue-toastification/dist/index.css"
|
||||
|
||||
const options = {
|
||||
hideProgressBar: true
|
||||
};
|
||||
hideProgressBar: true,
|
||||
draggable: false
|
||||
}
|
||||
|
||||
|
||||
Vue.use(Toast, options);
|
||||
Vue.use(Toast, options)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import packagejson from '../package.json'
|
||||
import axios from 'axios'
|
||||
|
||||
function parseSemver(ver) {
|
||||
if (!ver) return null
|
||||
var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/)
|
||||
if (groups && groups.length > 6) {
|
||||
var total = Number(groups[3]) * 100 + Number(groups[4]) * 10 + Number(groups[5])
|
||||
if (isNaN(total)) {
|
||||
console.warn('Invalid version total', groups[3], groups[4], groups[5])
|
||||
return null
|
||||
}
|
||||
return {
|
||||
total,
|
||||
version: groups[2],
|
||||
major: Number(groups[3]),
|
||||
minor: Number(groups[4]),
|
||||
patch: Number(groups[5]),
|
||||
preRelease: groups[6] || null
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid semver string', ver)
|
||||
}
|
||||
return null
|
||||
}
|
||||
export async function checkForUpdate() {
|
||||
if (!packagejson.version) {
|
||||
return
|
||||
}
|
||||
var currVerObj = parseSemver('v' + packagejson.version)
|
||||
if (!currVerObj) {
|
||||
console.error('Invalid version', packagejson.version)
|
||||
return
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
|
||||
var tags = res.data
|
||||
if (tags && tags.length) {
|
||||
tags.forEach((tag) => {
|
||||
var verObj = parseSemver(tag.name)
|
||||
if (verObj) {
|
||||
if (!largestVer || largestVer.total < verObj.total) {
|
||||
largestVer = verObj
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if (!largestVer) {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
}
|
||||
return {
|
||||
hasUpdate: largestVer.total > currVerObj.total,
|
||||
latestVersion: largestVer.version,
|
||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||
currentVersion: currVerObj.version
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,36 +1,47 @@
|
||||
import { sort } from '@/assets/fastSort'
|
||||
import { decode } from '@/plugins/init.client'
|
||||
|
||||
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
|
||||
|
||||
export const state = () => ({
|
||||
audiobooks: [],
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: []
|
||||
tags: [],
|
||||
series: [],
|
||||
keywordFilter: null
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getFiltered: (state, getters, rootState) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.settings.settings || {}
|
||||
var settings = rootState.user.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
var filterByParts = filterBy.split('.')
|
||||
if (filterByParts.length > 1) {
|
||||
var primary = filterByParts[0]
|
||||
var secondary = filterByParts[1]
|
||||
if (primary === 'genres') {
|
||||
filtered = filtered.filter(ab => {
|
||||
return ab.book && ab.book.genres.includes(secondary)
|
||||
})
|
||||
} else if (primary === 'tags') {
|
||||
filtered = filtered.filter(ab => ab.tags.includes(secondary))
|
||||
}
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filter = decode(filterBy.replace(`${group}.`, ''))
|
||||
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||
}
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
|
||||
const keyworkFilter = state.keywordFilter.toLowerCase()
|
||||
return filtered.filter(ab => {
|
||||
if (!ab.book) return false
|
||||
return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter)))
|
||||
})
|
||||
}
|
||||
// TODO: Add filters
|
||||
return filtered
|
||||
},
|
||||
getFilteredAndSorted: (state, getters, rootState) => () => {
|
||||
var settings = rootState.settings.settings
|
||||
var settings = rootState.user.settings
|
||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||
|
||||
var filtered = getters.getFiltered()
|
||||
@@ -38,11 +49,24 @@ export const getters = {
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
})
|
||||
},
|
||||
getUniqueAuthors: (state) => {
|
||||
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
||||
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
},
|
||||
getGenresUsed: (state) => {
|
||||
var _genres = []
|
||||
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
||||
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
load({ commit }) {
|
||||
load({ commit, rootState }) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('audiobooks/load - User not set')
|
||||
return
|
||||
}
|
||||
this.$axios
|
||||
.$get(`/api/audiobooks`)
|
||||
.then((data) => {
|
||||
@@ -57,6 +81,9 @@ export const actions = {
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setKeywordFilter(state, val) {
|
||||
state.keywordFilter = val
|
||||
},
|
||||
set(state, audiobooks) {
|
||||
// GENRES
|
||||
var genres = [...state.genres]
|
||||
@@ -65,6 +92,7 @@ export const mutations = {
|
||||
genres = genres.concat(ab.book.genres)
|
||||
})
|
||||
state.genres = [...new Set(genres)] // Remove Duplicates
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// TAGS
|
||||
var tags = []
|
||||
@@ -72,6 +100,16 @@ export const mutations = {
|
||||
tags = tags.concat(ab.tags)
|
||||
})
|
||||
state.tags = [...new Set(tags)] // Remove Duplicates
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// SERIES
|
||||
var series = []
|
||||
audiobooks.forEach((ab) => {
|
||||
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
|
||||
series.push(ab.book.series)
|
||||
})
|
||||
state.series = series
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
state.audiobooks = audiobooks
|
||||
state.listeners.forEach((listener) => {
|
||||
@@ -80,19 +118,34 @@ export const mutations = {
|
||||
},
|
||||
addUpdate(state, audiobook) {
|
||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||
var origAudiobook = null
|
||||
if (index >= 0) {
|
||||
origAudiobook = { ...state.audiobooks[index] }
|
||||
state.audiobooks.splice(index, 1, audiobook)
|
||||
} else {
|
||||
state.audiobooks.push(audiobook)
|
||||
}
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
var newGenres = []
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!state.genres.includes(genre)) newGenres.push(genre)
|
||||
})
|
||||
if (newGenres.length) state.genres = state.genres.concat(newGenres)
|
||||
if (newGenres.length) {
|
||||
state.genres = state.genres.concat(newGenres)
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
|
||||
state.series.push(audiobook.book.series)
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
|
||||
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
|
||||
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -100,8 +153,10 @@ export const mutations = {
|
||||
audiobook.tags.forEach((tag) => {
|
||||
if (!state.tags.includes(tag)) newTags.push(tag)
|
||||
})
|
||||
if (newTags.length) state.tags = state.tags.concat(newTags)
|
||||
|
||||
if (newTags.length) {
|
||||
state.tags = state.tags.concat(newTags)
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
@@ -112,8 +167,8 @@ export const mutations = {
|
||||
remove(state, audiobook) {
|
||||
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!STANDARD_GENRES.includes(genre)) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => {
|
||||
@@ -125,6 +180,15 @@ export const mutations = {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
|
||||
if (!isInOtherAB) {
|
||||
// Series not used in any other audiobook - remove it
|
||||
state.series = state.series.filter(s => s !== audiobook.book.series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -138,7 +202,6 @@ export const mutations = {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
listener.meth()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
export const state = () => ({
|
||||
downloads: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getDownloads: (state) => (audiobookId) => {
|
||||
return state.downloads.filter(d => d.audiobookId === audiobookId)
|
||||
},
|
||||
getDownload: (state) => (id) => {
|
||||
return state.downloads.find(d => d.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
addUpdateDownload(state, download) {
|
||||
// Remove older downloads of matching type
|
||||
state.downloads = state.downloads.filter(d => {
|
||||
if (d.id !== download.id && d.type === download.type) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
var index = state.downloads.findIndex(d => d.id === download.id)
|
||||
if (index >= 0) {
|
||||
state.downloads.splice(index, 1, download)
|
||||
} else {
|
||||
state.downloads.push(download)
|
||||
}
|
||||
},
|
||||
removeDownload(state, download) {
|
||||
state.downloads = state.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,65 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
versionData: null,
|
||||
serverSettings: null,
|
||||
streamAudiobook: null,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
selectedAudiobook: null,
|
||||
playOnLoad: false,
|
||||
isScanning: false,
|
||||
scanProgress: null
|
||||
isScanningCovers: false,
|
||||
scanProgress: null,
|
||||
coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
getIsAudiobookSelected: state => audiobookId => {
|
||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
}
|
||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
updateServerSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setServerSettings', result.settings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
commit('setVersionData', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user.token) {
|
||||
localStorage.setItem('token', user.token)
|
||||
}
|
||||
setVersionData(state, versionData) {
|
||||
state.versionData = versionData
|
||||
},
|
||||
setServerSettings(state, settings) {
|
||||
state.serverSettings = settings
|
||||
},
|
||||
setStreamAudiobook(state, audiobook) {
|
||||
state.playOnLoad = true
|
||||
@@ -47,17 +79,49 @@ export const mutations = {
|
||||
state.playOnLoad = val
|
||||
},
|
||||
showEditModal(state, audiobook) {
|
||||
state.editModalTab = 'details'
|
||||
state.selectedAudiobook = audiobook
|
||||
state.showEditModal = true
|
||||
},
|
||||
showEditModalOnTab(state, { audiobook, tab }) {
|
||||
state.editModalTab = tab
|
||||
state.selectedAudiobook = audiobook
|
||||
state.showEditModal = true
|
||||
},
|
||||
setEditModalTab(state, tab) {
|
||||
state.editModalTab = tab
|
||||
},
|
||||
setShowEditModal(state, val) {
|
||||
state.showEditModal = val
|
||||
},
|
||||
setIsScanning(state, isScanning) {
|
||||
state.isScanning = isScanning
|
||||
},
|
||||
setScanProgress(state, progress) {
|
||||
if (progress > 0) state.isScanning = true
|
||||
state.scanProgress = progress
|
||||
setScanProgress(state, scanProgress) {
|
||||
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
||||
state.scanProgress = scanProgress
|
||||
},
|
||||
setIsScanningCovers(state, isScanningCovers) {
|
||||
state.isScanningCovers = isScanningCovers
|
||||
},
|
||||
setCoverScanProgress(state, coverScanProgress) {
|
||||
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
|
||||
state.coverScanProgress = coverScanProgress
|
||||
},
|
||||
setDeveloperMode(state, val) {
|
||||
state.developerMode = val
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
state.selectedAudiobooks = audiobooks
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
export const state = () => ({
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all'
|
||||
},
|
||||
|
||||
listeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setSettings(state, settings) {
|
||||
state.settings = {
|
||||
...settings
|
||||
}
|
||||
state.listeners.forEach((listener) => {
|
||||
listener.meth()
|
||||
})
|
||||
},
|
||||
addListener(state, listener) {
|
||||
var index = state.listeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.listeners.splice(index, 1, listener)
|
||||
else state.listeners.push(listener)
|
||||
},
|
||||
removeListener(state, listenerId) {
|
||||
state.listeners = state.listeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
bookshelfCoverSize: 120
|
||||
},
|
||||
settingsListeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
return state.settings ? state.settings[key] || null : null
|
||||
},
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
},
|
||||
getUserCanUpdate: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.update : false
|
||||
},
|
||||
getUserCanDelete: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.delete : false
|
||||
},
|
||||
getUserCanDownload: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
updateUserSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setSettings', result.settings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update settings', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
console.log('setUser', user.username)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
console.warn('setUser cleared')
|
||||
}
|
||||
},
|
||||
setSettings(state, settings) {
|
||||
if (!settings) return
|
||||
|
||||
var hasChanges = false
|
||||
for (const key in settings) {
|
||||
if (state.settings[key] !== settings[key]) {
|
||||
hasChanges = true
|
||||
state.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
state.settingsListeners.forEach((listener) => {
|
||||
listener.meth(state.settings)
|
||||
})
|
||||
}
|
||||
},
|
||||
addSettingsListener(state, listener) {
|
||||
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
|
||||
else state.settingsListeners.push(listener)
|
||||
},
|
||||
removeSettingsListener(state, listenerId) {
|
||||
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ module.exports = {
|
||||
purge: {
|
||||
options: {
|
||||
safelist: [
|
||||
'bg-success'
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'py-1.5'
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -16,13 +18,13 @@ module.exports = {
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#262626',
|
||||
primary: '#232323',
|
||||
accent: '#1ad691',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
successDark: '#3b8a3e',
|
||||
warning: '#FB8C00',
|
||||
darkgreen: 'rgb(34,127,35)',
|
||||
'black-50': '#bbbbbb',
|
||||
'black-100': '#666666',
|
||||
'black-200': '#555555',
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<MyIP/>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://hub.docker.com/r/advplyr/audiobookshelf/</Support>
|
||||
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
||||
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
||||
<Overview>Audiobook manager and player</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books Status:Beta<</Category>
|
||||
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books</Category>
|
||||
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
||||
<TemplateURL/>
|
||||
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
||||
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
@@ -20,7 +20,7 @@
|
||||
<DateInstalled>1629238508</DateInstalled>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Description>Audiobook manager and player</Description>
|
||||
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
|
||||
<Networking>
|
||||
<Mode>bridge</Mode>
|
||||
<Publish>
|
||||
|
||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -11,6 +11,7 @@ if (isDev) {
|
||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "0.9.61-beta",
|
||||
"version": "1.1.9",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"start": "node index.js",
|
||||
"release": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks",
|
||||
"release-dry": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks --dry-run"
|
||||
"start": "node index.js"
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"archiver": "^5.3.0",
|
||||
"axios": "^0.21.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chokidar": "^3.5.2",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"ip": "^1.1.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-dir": "^0.1.17",
|
||||
"socket.io": "^4.1.3"
|
||||
"podcast": "^1.3.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"watcher": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^4.0.0",
|
||||
"release-it": "^14.11.5"
|
||||
}
|
||||
}
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
**Currently in early beta**
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_bookshelf.png" />
|
||||
**Free & open source Android/iOS app is in development**
|
||||
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
|
||||
#### Folder Structures Supported:
|
||||
@@ -15,18 +17,22 @@ AudioBookshelf is a self-hosted audiobook server for managing and playing your a
|
||||
/Author/Series/Title/...
|
||||
|
||||
Title can start with the publish year like so:
|
||||
/1989 - Awesome Book/...
|
||||
/1989 - Book Title/...
|
||||
|
||||
(Optional Setting) Subtitle can be seperated to its own field:
|
||||
/Book Title - With a Subtitle/...
|
||||
/1989 - Book Title - With a Subtitle/...
|
||||
will store "With a Subtitle" as the subtitle
|
||||
```
|
||||
|
||||
|
||||
#### There is still a lot to do:
|
||||
#### Features coming soon:
|
||||
|
||||
* Adding new audiobooks require pressing Scan button again (on settings page)
|
||||
* Matching is all manual now and only using 1 source (openlibrary)
|
||||
* Support different views to see more details of each audiobook
|
||||
* Then comes the mobile app..
|
||||
* Option to download all files in a zip file
|
||||
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
|
||||
|
||||
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -36,8 +42,6 @@ Built to run in Docker for now (also on Unraid server Community Apps)
|
||||
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
|
||||
```
|
||||
|
||||
<img alt="Screenshot3" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to help out
|
||||
@@ -1,13 +1,18 @@
|
||||
const express = require('express')
|
||||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject } = require('./utils/index')
|
||||
|
||||
class ApiController {
|
||||
constructor(db, scanner, auth, streamManager, emitter) {
|
||||
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
@@ -17,8 +22,11 @@ class ApiController {
|
||||
this.router.get('/find/covers', this.findCovers.bind(this))
|
||||
this.router.get('/find/:method', this.find.bind(this))
|
||||
|
||||
|
||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
|
||||
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
||||
|
||||
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
|
||||
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
||||
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
||||
@@ -28,10 +36,23 @@ class ApiController {
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/password', this.userChangePassword.bind(this))
|
||||
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
||||
this.router.get('/users', this.getUsers.bind(this))
|
||||
this.router.post('/user', this.createUser.bind(this))
|
||||
this.router.patch('/user/:id', this.updateUser.bind(this))
|
||||
this.router.delete('/user/:id', this.deleteUser.bind(this))
|
||||
|
||||
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
|
||||
|
||||
this.router.post('/authorize', this.authorize.bind(this))
|
||||
|
||||
this.router.get('/genres', this.getGenres.bind(this))
|
||||
|
||||
this.router.post('/feed', this.openRssFeed.bind(this))
|
||||
|
||||
this.router.get('/download/:id', this.download.bind(this))
|
||||
}
|
||||
|
||||
find(req, res) {
|
||||
@@ -39,7 +60,6 @@ class ApiController {
|
||||
}
|
||||
|
||||
findCovers(req, res) {
|
||||
console.log('Find covers', req.query)
|
||||
this.scanner.findCovers(req, res)
|
||||
}
|
||||
|
||||
@@ -57,9 +77,26 @@ class ApiController {
|
||||
}
|
||||
|
||||
getAudiobooks(req, res) {
|
||||
Logger.info('Get Audiobooks')
|
||||
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
||||
res.json(audiobooksMinified)
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
audiobooks = this.db.audiobooks.filter(ab => {
|
||||
return ab.isSearchMatch(req.query.q)
|
||||
}).map(ab => ab.toJSONMinified())
|
||||
} else {
|
||||
audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
||||
}
|
||||
res.json(audiobooks)
|
||||
}
|
||||
|
||||
async deleteAllAudiobooks(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.warn('User other than root attempted to delete all audiobooks', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
Logger.info('Removing all Audiobooks')
|
||||
var success = await this.db.recreateAudiobookDb()
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
getAudiobook(req, res) {
|
||||
@@ -68,14 +105,11 @@ class ApiController {
|
||||
res.json(audiobook.toJSONExpanded())
|
||||
}
|
||||
|
||||
async deleteAudiobook(req, res) {
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
async handleDeleteAudiobook(audiobook) {
|
||||
// Remove audiobook from users
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var user = this.db.users[i]
|
||||
var madeUpdates = user.resetAudiobookProgress(audiobook.id)
|
||||
var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', user)
|
||||
}
|
||||
@@ -94,24 +128,98 @@ class ApiController {
|
||||
}
|
||||
}
|
||||
|
||||
var audiobookJSON = audiobook.toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', audiobook.id)
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
}
|
||||
|
||||
this.emitter('audiobook_removed', audiobook.toJSONMinified())
|
||||
async deleteAudiobook(req, res) {
|
||||
if (!req.user.canDelete) {
|
||||
Logger.warn('User attempted to delete without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
await this.handleDeleteAudiobook(audiobook)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchDeleteAudiobooks(req, res) {
|
||||
if (!req.user.canDelete) {
|
||||
Logger.warn('User attempted to delete without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var { audiobookIds } = req.body
|
||||
if (!audiobookIds || !audiobookIds.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id))
|
||||
if (!audiobooksToDelete.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < audiobooksToDelete.length; i++) {
|
||||
Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`)
|
||||
await this.handleDeleteAudiobook(audiobooksToDelete[i])
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchUpdateAudiobooks(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to batch update without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobooks = req.body
|
||||
if (!audiobooks || !audiobooks.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var audiobooksUpdated = 0
|
||||
audiobooks = audiobooks.map((ab) => {
|
||||
var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id)
|
||||
if (!_ab) return null
|
||||
var hasUpdated = _ab.update(ab)
|
||||
if (!hasUpdated) return null
|
||||
audiobooksUpdated++
|
||||
return _ab
|
||||
}).filter(ab => ab)
|
||||
|
||||
if (audiobooksUpdated) {
|
||||
Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`)
|
||||
for (let i = 0; i < audiobooks.length; i++) {
|
||||
await this.db.updateAudiobook(audiobooks[i])
|
||||
this.emitter('audiobook_updated', audiobooks[i].toJSONMinified())
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updates: audiobooksUpdated
|
||||
})
|
||||
}
|
||||
|
||||
async updateAudiobookTracks(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update audiotracks without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
var files = req.body.files
|
||||
var orderedFileData = req.body.orderedFileData
|
||||
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
|
||||
audiobook.updateAudioTracks(files)
|
||||
audiobook.updateAudioTracks(orderedFileData)
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
res.json(audiobook.toJSON())
|
||||
}
|
||||
|
||||
async updateAudiobook(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
var hasUpdates = audiobook.update(req.body)
|
||||
@@ -142,13 +250,184 @@ class ApiController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getUsers(req, res) {
|
||||
if (req.user.type !== 'root') return res.sendStatus(403)
|
||||
return res.json(this.db.users.map(u => u.toJSONForBrowser()))
|
||||
}
|
||||
|
||||
async resetUserAudiobookProgress(req, res) {
|
||||
req.user.resetAudiobookProgress(req.params.id)
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.emitter('user_updated', req.user.toJSONForBrowser())
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async updateUserAudiobookProgress(req, res) {
|
||||
var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
userChangePassword(req, res) {
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
async openRssFeed(req, res) {
|
||||
var audiobookId = req.body.audiobookId
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
var feed = await this.rssFeeds.openFeed(audiobook)
|
||||
console.log('Feed open', feed)
|
||||
res.json(feed)
|
||||
}
|
||||
|
||||
async userUpdateSettings(req, res) {
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var madeUpdates = req.user.updateSettings(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
settings: req.user.settings
|
||||
})
|
||||
}
|
||||
|
||||
async createUser(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.warn('Non-root user attempted to create user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var account = req.body
|
||||
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
account.pash = await this.auth.hashPass(account.password)
|
||||
delete account.password
|
||||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
||||
account.createdAt = Date.now()
|
||||
var newUser = new User(account)
|
||||
var success = await this.db.insertUser(newUser)
|
||||
if (success) {
|
||||
this.clientEmitter(req.user.id, 'user_added', newUser)
|
||||
res.json({
|
||||
user: newUser.toJSONForBrowser()
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Failed to save new user'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to update user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var account = req.body
|
||||
// Updating password
|
||||
if (account.password) {
|
||||
account.pash = await this.auth.hashPass(account.password)
|
||||
delete account.password
|
||||
}
|
||||
|
||||
var hasUpdated = user.update(account)
|
||||
if (hasUpdated) {
|
||||
await this.db.updateEntity('user', user)
|
||||
}
|
||||
|
||||
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
|
||||
res.json({
|
||||
success: true,
|
||||
user: user.toJSONForBrowser()
|
||||
})
|
||||
}
|
||||
|
||||
async deleteUser(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to delete user', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (req.params.id === 'root') {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error('Attempting to delete themselves...')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
Logger.error('User not found')
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
var userJson = user.toJSONForBrowser()
|
||||
await this.db.removeEntity('user', user.id)
|
||||
this.clientEmitter(req.user.id, 'user_removed', userJson)
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
}
|
||||
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error('User other than root attempting to update server settings', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('settings', this.db.serverSettings)
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
serverSettings: this.db.serverSettings
|
||||
})
|
||||
}
|
||||
|
||||
async download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var downloadId = req.params.id
|
||||
Logger.info('Download Request', downloadId)
|
||||
var download = this.downloadManager.getDownload(downloadId)
|
||||
if (!download) {
|
||||
Logger.error('Download request not found', downloadId)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var options = {
|
||||
headers: {
|
||||
'Content-Type': download.mimeType
|
||||
}
|
||||
}
|
||||
res.download(download.fullPath, download.filename, options, (err) => {
|
||||
if (err) {
|
||||
Logger.error('Download Error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getGenres(req, res) {
|
||||
res.json({
|
||||
genres: this.db.getGenres()
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
|
||||
class Audiobook {
|
||||
constructor(audiobook = null) {
|
||||
this.id = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
this.invalidParts = []
|
||||
|
||||
this.audioFiles = []
|
||||
this.otherFiles = []
|
||||
|
||||
this.tags = []
|
||||
this.book = null
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
}
|
||||
|
||||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => {
|
||||
return new AudioTrack(track)
|
||||
})
|
||||
this.missingParts = audiobook.missingParts
|
||||
this.invalidParts = audiobook.invalidParts
|
||||
|
||||
this.audioFiles = audiobook.audioFiles
|
||||
this.otherFiles = audiobook.otherFiles
|
||||
|
||||
this.tags = audiobook.tags
|
||||
if (audiobook.book) {
|
||||
this.book = new Book(audiobook.book)
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
}
|
||||
|
||||
get cover() {
|
||||
return this.book ? this.book.cover : ''
|
||||
}
|
||||
|
||||
get author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
|
||||
get totalDuration() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.duration)
|
||||
return total
|
||||
}
|
||||
|
||||
get totalSize() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.size)
|
||||
return total
|
||||
}
|
||||
|
||||
get sizePretty() {
|
||||
return bytesPretty(this.totalSize)
|
||||
}
|
||||
|
||||
get durationPretty() {
|
||||
return elapsedPretty(this.totalDuration)
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
|
||||
tracksToJSON() {
|
||||
if (!this.tracks || !this.tracks.length) return []
|
||||
return this.tracks.map(t => t.toJSON())
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: this.audioFiles,
|
||||
otherFiles: this.otherFiles
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
duration: this.totalDuration,
|
||||
size: this.totalSize,
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numTracks: this.tracks.length
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
duration: this.totalDuration,
|
||||
durationPretty: this.durationPretty,
|
||||
size: this.totalSize,
|
||||
sizePretty: this.sizePretty,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: this.audioFiles,
|
||||
otherFiles: this.otherFiles,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON()
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
|
||||
this.otherFiles = data.otherFiles || []
|
||||
this.setBook(data)
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
||||
addTrack(trackData) {
|
||||
var track = new AudioTrack()
|
||||
track.setData(trackData)
|
||||
this.tracks.push(track)
|
||||
return track
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
||||
this.tags = payload.tags
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.book) {
|
||||
if (!this.book) {
|
||||
this.setBook(payload.book)
|
||||
hasUpdates = true
|
||||
} else if (this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateAudioTracks(files) {
|
||||
var index = 1
|
||||
this.audioFiles = files.map((file) => {
|
||||
file.manuallyVerified = true
|
||||
file.invalid = false
|
||||
file.error = null
|
||||
file.index = index++
|
||||
return file
|
||||
})
|
||||
this.tracks = []
|
||||
this.invalidParts = []
|
||||
this.missingParts = []
|
||||
this.audioFiles.forEach((file) => {
|
||||
this.addTrack(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -2,7 +2,6 @@ const bcrypt = require('bcryptjs')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
|
||||
class Auth {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
@@ -42,13 +41,17 @@ class Auth {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token')
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
var user = await this.verifyToken(token)
|
||||
if (!user) {
|
||||
Logger.error('Verify Token User Not Found', token)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Verify Token User is disabled', token, user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
req.user = user
|
||||
@@ -69,12 +72,16 @@ class Auth {
|
||||
}
|
||||
|
||||
generateAccessToken(payload) {
|
||||
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' });
|
||||
return jwt.sign(payload, process.env.TOKEN_SECRET);
|
||||
}
|
||||
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
return resolve(null)
|
||||
}
|
||||
var user = this.users.find(u => u.id === payload.userId)
|
||||
resolve(user || null)
|
||||
})
|
||||
@@ -86,12 +93,16 @@ class Auth {
|
||||
var password = req.body.password || ''
|
||||
Logger.debug('Check Auth', username, !!password)
|
||||
|
||||
var user = this.users.find(u => u.id === username)
|
||||
var user = this.users.find(u => u.username === username)
|
||||
|
||||
if (!user) {
|
||||
return res.json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.json({ error: 'User unavailable' })
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
@@ -114,65 +125,50 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
async checkAuth(req, res) {
|
||||
var username = req.body.username
|
||||
Logger.debug('Check Auth', username, !!req.body.password)
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
var matchingUser = this.users.find(u => u.username === username)
|
||||
if (!matchingUser) {
|
||||
async userChangePassword(req, res) {
|
||||
var { password, newPassword } = req.body
|
||||
newPassword = newPassword || ''
|
||||
var matchingUser = this.users.find(u => u.id === req.user.id)
|
||||
|
||||
// Only root can have an empty password
|
||||
if (matchingUser.type !== 'root' && !newPassword) {
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
})
|
||||
}
|
||||
|
||||
var cleanedUser = { ...matchingUser }
|
||||
delete cleanedUser.pash
|
||||
|
||||
// check for empty password (default)
|
||||
if (!req.body.password) {
|
||||
if (!matchingUser.pash) {
|
||||
res.cookie('user', username, { signed: true })
|
||||
return res.json({
|
||||
user: cleanedUser
|
||||
})
|
||||
} else {
|
||||
return res.json({
|
||||
error: 'Invalid Password'
|
||||
})
|
||||
}
|
||||
var compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
error: 'Invalid password'
|
||||
})
|
||||
}
|
||||
|
||||
// Set root password first time
|
||||
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
|
||||
console.log('Set root pash')
|
||||
var pw = await this.hashPass(req.body.password)
|
||||
var pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPass(newPassword)
|
||||
if (!pw) {
|
||||
return res.json({
|
||||
error: 'Hash failed'
|
||||
})
|
||||
}
|
||||
this.users = this.users.map(u => {
|
||||
if (u.username === matchingUser.username) {
|
||||
u.pash = pw
|
||||
}
|
||||
return u
|
||||
})
|
||||
await this.saveAuthDb()
|
||||
return res.json({
|
||||
setroot: true,
|
||||
user: cleanedUser
|
||||
})
|
||||
}
|
||||
|
||||
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
|
||||
if (compare) {
|
||||
res.cookie('user', username, { signed: true })
|
||||
matchingUser.pash = pw
|
||||
var success = await this.db.updateEntity('user', matchingUser)
|
||||
if (success) {
|
||||
res.json({
|
||||
user: cleanedUser
|
||||
success: true
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Invalid Password'
|
||||
error: 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
const Path = require('path')
|
||||
class Book {
|
||||
constructor(book = null) {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.author = null
|
||||
this.series = null
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.genres = []
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
}
|
||||
}
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.author = book.author
|
||||
this.series = book.series
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.genres = book.genres
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
series: this.series,
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
genres: this.genres
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.author = data.author || null
|
||||
this.series = data.series || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.genres = data.genres || []
|
||||
|
||||
// Use first image file as cover
|
||||
if (data.otherFiles && data.otherFiles.length) {
|
||||
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
this.cover = Path.join('/local', imageFile.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (payload[key] === undefined) continue;
|
||||
|
||||
if (key === 'genres') {
|
||||
if (payload['genres'] === null && this.genres !== null) {
|
||||
this.genres = []
|
||||
hasUpdates = true
|
||||
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
|
||||
this.genres = payload['genres']
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
@@ -12,7 +12,7 @@ class BookFinder {
|
||||
async findByISBN(isbn) {
|
||||
var book = await this.openLibrary.isbnLookup(isbn)
|
||||
if (book.errorCode) {
|
||||
console.error('Book not found')
|
||||
Logger.error('Book not found')
|
||||
}
|
||||
return book
|
||||
}
|
||||
@@ -26,7 +26,17 @@ class BookFinder {
|
||||
return title
|
||||
}
|
||||
|
||||
replaceAccentedChars(str) {
|
||||
try {
|
||||
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
} catch (error) {
|
||||
Logger.error('[BookFinder] str normalize error', error)
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
cleanTitleForCompares(title) {
|
||||
if (!title) return ''
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
var stripped = this.stripSubtitle(title)
|
||||
|
||||
@@ -35,71 +45,98 @@ class BookFinder {
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
cleaned = this.replaceAccentedChars(cleaned)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
var cleaned = this.replaceAccentedChars(author)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var searchTitle = this.cleanTitleForCompares(title)
|
||||
var searchAuthor = this.cleanAuthorForCompares(author)
|
||||
return books.map(b => {
|
||||
b.cleanedTitle = this.cleanTitleForCompares(b.title)
|
||||
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
|
||||
if (author) {
|
||||
b.authorDistance = levenshteinDistance(b.author || '', author)
|
||||
}
|
||||
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
|
||||
|
||||
// Total length of search (title or both title & author)
|
||||
b.totalPossibleDistance = b.title.length
|
||||
|
||||
if (b.cleanedTitle.includes(searchTitle) && searchTitle.length > 4) {
|
||||
b.includesSearch = searchTitle
|
||||
} else if (b.title.includes(searchTitle) && searchTitle.length > 4) {
|
||||
b.includesSearch = searchTitle
|
||||
}
|
||||
if (author) {
|
||||
if (!b.author) {
|
||||
b.authorDistance = author.length
|
||||
} else {
|
||||
b.totalPossibleDistance += b.author.length
|
||||
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
|
||||
|
||||
if (author && b.author) b.totalPossibleDistance += b.author.length
|
||||
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
|
||||
var authorDistance = levenshteinDistance(b.author || '', author)
|
||||
|
||||
// Use best distance
|
||||
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
|
||||
|
||||
// Check book author contains searchAuthor
|
||||
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
|
||||
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
|
||||
}
|
||||
}
|
||||
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
|
||||
|
||||
// Check book title contains the searchTitle
|
||||
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
|
||||
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
|
||||
|
||||
return b
|
||||
}).filter(b => {
|
||||
if (b.includesSearch) { // If search was found in result title exactly then skip over leven distance check
|
||||
Logger.debug(`Exact search was found inside title ${b.cleanedTitle}/${b.includesSearch}`)
|
||||
if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
|
||||
Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
|
||||
} else if (b.titleDistance > maxTitleDistance) {
|
||||
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (author && b.authorDistance > maxAuthorDistance) {
|
||||
Logger.debug(`Filtering out search result "${b.title}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
return false
|
||||
if (author) {
|
||||
if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
|
||||
Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
|
||||
} else if (b.authorDistance > maxAuthorDistance) {
|
||||
Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (b.totalPossibleDistance < 4 && b.totalDistance > 0) return false
|
||||
// If book total search length < 5 and was not exact match, then filter out
|
||||
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.libGen.search(title)
|
||||
Logger.info(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`LibGen Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
||||
if (!booksFiltered.length && books.length) {
|
||||
Logger.info(`Search has ${books.length} matches, but no close title matches`)
|
||||
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
|
||||
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.openLibrary.searchTitle(title)
|
||||
Logger.info(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`OpenLib Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
||||
if (!booksFiltered.length && books.length) {
|
||||
Logger.info(`Search has ${books.length} matches, but no close title matches`)
|
||||
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
@@ -108,7 +145,7 @@ class BookFinder {
|
||||
var books = []
|
||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
Logger.info(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
Logger.debug(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
@@ -119,18 +156,16 @@ class BookFinder {
|
||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks, olBooks)
|
||||
} else {
|
||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var hasCloseMatch = olBooks.find(b => (b.totalDistance < 4 && b.totalPossibleDistance > 4))
|
||||
if (hasCloseMatch) {
|
||||
books = olBooks
|
||||
} else {
|
||||
Logger.info(`Book Search, LibGen has no close matches - get openlib results also`)
|
||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
|
||||
if (!hasCloseMatch) {
|
||||
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
|
||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks)
|
||||
}
|
||||
|
||||
if (!books.length && author) {
|
||||
Logger.info(`Book Search, no matches for title and author.. check title only`)
|
||||
if (!books.length && author && options.fallbackTitleOnly) {
|
||||
Logger.debug(`Book Search, no matches for title and author.. check title only`)
|
||||
return this.search(provider, title, null, options)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +177,8 @@ class BookFinder {
|
||||
|
||||
async findCovers(provider, title, author, options = {}) {
|
||||
var searchResults = await this.search(provider, title, author, options)
|
||||
console.log('Find Covers search results', searchResults)
|
||||
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
||||
|
||||
var covers = []
|
||||
searchResults.forEach((result) => {
|
||||
if (result.covers && result.covers.length) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const njodb = require("njodb")
|
||||
const jwt = require('jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
const Audiobook = require('./Audiobook')
|
||||
const User = require('./User')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const User = require('./objects/User')
|
||||
const ServerSettings = require('./objects/ServerSettings')
|
||||
|
||||
class Db {
|
||||
constructor(CONFIG_PATH) {
|
||||
@@ -20,6 +20,8 @@ class Db {
|
||||
this.users = []
|
||||
this.audiobooks = []
|
||||
this.settings = []
|
||||
|
||||
this.serverSettings = null
|
||||
}
|
||||
|
||||
getEntityDb(entityName) {
|
||||
@@ -28,21 +30,18 @@ class Db {
|
||||
return this.settingsDb
|
||||
}
|
||||
|
||||
getEntityDbKey(entityName) {
|
||||
if (entityName === 'user') return 'usersDb'
|
||||
else if (entityName === 'audiobook') return 'audiobooksDb'
|
||||
return 'settingsDb'
|
||||
}
|
||||
|
||||
getEntityArrayKey(entityName) {
|
||||
if (entityName === 'user') return 'users'
|
||||
else if (entityName === 'audiobook') return 'audiobooks'
|
||||
return 'settings'
|
||||
}
|
||||
|
||||
getDefaultSettings() {
|
||||
return {
|
||||
config: {
|
||||
version: 1,
|
||||
cardSize: 'md'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultUser(token) {
|
||||
return new User({
|
||||
id: 'root',
|
||||
@@ -52,6 +51,7 @@ class Db {
|
||||
pash: '',
|
||||
stream: null,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -65,23 +65,43 @@ class Db {
|
||||
Logger.debug('Generated default token', token)
|
||||
await this.insertUser(this.getDefaultUser(token))
|
||||
}
|
||||
|
||||
if (!this.serverSettings) {
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertSettings(this.serverSettings)
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
||||
this.audiobooks = results.data.map(a => new Audiobook(a))
|
||||
Logger.info(`Audiobooks Loaded ${this.audiobooks.length}`)
|
||||
Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
|
||||
})
|
||||
var p2 = this.usersDb.select(() => true).then((results) => {
|
||||
this.users = results.data.map(u => new User(u))
|
||||
Logger.info(`Users Loaded ${this.users.length}`)
|
||||
Logger.info(`[DB] Users Loaded ${this.users.length}`)
|
||||
})
|
||||
var p3 = this.settingsDb.select(() => true).then((results) => {
|
||||
this.settings = results
|
||||
if (results.data && results.data.length) {
|
||||
this.settings = results.data
|
||||
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
||||
if (serverSettings) {
|
||||
this.serverSettings = new ServerSettings(serverSettings)
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all([p1, p2, p3])
|
||||
}
|
||||
|
||||
insertSettings(settings) {
|
||||
return this.settingsDb.insert([settings]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||
this.settings = this.settings.concat(settings)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||
})
|
||||
}
|
||||
|
||||
insertAudiobook(audiobook) {
|
||||
return this.insertAudiobooks([audiobook])
|
||||
}
|
||||
@@ -98,8 +118,10 @@ class Db {
|
||||
updateAudiobook(audiobook) {
|
||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Audiobook update failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,8 +129,10 @@ class Db {
|
||||
return this.usersDb.insert([user]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||
this.users.push(user)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert user Failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,8 +161,10 @@ class Db {
|
||||
this[arrayKey] = this[arrayKey].map(e => {
|
||||
return e.id === entity.id ? entity : e
|
||||
})
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +181,18 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
recreateAudiobookDb() {
|
||||
return this.audiobooksDb.drop().then((results) => {
|
||||
Logger.info(`[DB] Dropped audiobook db`, results)
|
||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||
this.audiobooks = []
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Failed to drop audiobook db`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
getGenres() {
|
||||
var allGenres = []
|
||||
this.db.audiobooks.forEach((audiobook) => {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const archiver = require('archiver')
|
||||
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('./Logger')
|
||||
const Download = require('./objects/Download')
|
||||
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('./utils/fileUtils')
|
||||
|
||||
class DownloadManager {
|
||||
constructor(db, MetadataPath, AudiobookPath, emitter) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.AudiobookPath = AudiobookPath
|
||||
this.emitter = emitter
|
||||
|
||||
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
|
||||
|
||||
this.pendingDownloads = []
|
||||
this.downloads = []
|
||||
}
|
||||
|
||||
getDownload(downloadId) {
|
||||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
downloadSocketRequest(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
|
||||
var options = {
|
||||
...payload
|
||||
}
|
||||
delete options.audiobookId
|
||||
this.prepareDownload(client, audiobook, options)
|
||||
}
|
||||
|
||||
removeSocketRequest(socket, downloadId) {
|
||||
var download = this.downloads.find(d => d.id === downloadId)
|
||||
if (!download) {
|
||||
Logger.error('Remove download request download not found ' + downloadId)
|
||||
return
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async prepareDownload(client, audiobook, options = {}) {
|
||||
var downloadId = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
||||
Logger.info(`Start Download for ${audiobook.id} - DownloadId: ${downloadId} - ${dlpath}`)
|
||||
|
||||
await fs.ensureDir(dlpath)
|
||||
|
||||
var downloadType = options.type || 'singleAudio'
|
||||
delete options.type
|
||||
|
||||
|
||||
var fileext = null
|
||||
var audiobookDirname = Path.basename(audiobook.path)
|
||||
|
||||
if (downloadType === 'singleAudio') {
|
||||
var audioFileType = options.audioFileType || '.m4b'
|
||||
delete options.audioFileType
|
||||
if (audioFileType === 'same') {
|
||||
var firstTrack = audiobook.tracks[0]
|
||||
audioFileType = firstTrack.ext
|
||||
}
|
||||
fileext = audioFileType
|
||||
} else if (downloadType === 'zip') {
|
||||
fileext = '.zip'
|
||||
}
|
||||
var filename = audiobookDirname + fileext
|
||||
var downloadData = {
|
||||
id: downloadId,
|
||||
audiobookId: audiobook.id,
|
||||
type: downloadType,
|
||||
options: options,
|
||||
dirpath: dlpath,
|
||||
fullPath: Path.join(dlpath, filename),
|
||||
filename,
|
||||
ext: fileext,
|
||||
userId: (client && client.user) ? client.user.id : null,
|
||||
socket: (client && client.socket) ? client.socket : null
|
||||
}
|
||||
var download = new Download()
|
||||
download.setData(downloadData)
|
||||
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
|
||||
|
||||
if (downloadData.socket) {
|
||||
downloadData.socket.emit('download_started', download.toJSON())
|
||||
}
|
||||
|
||||
if (download.type === 'singleAudio') {
|
||||
this.processSingleAudioDownload(audiobook, download)
|
||||
} else if (download.type === 'zip') {
|
||||
this.processZipDownload(audiobook, download)
|
||||
}
|
||||
}
|
||||
|
||||
async processZipDownload(audiobook, download) {
|
||||
this.pendingDownloads.push({
|
||||
id: download.id,
|
||||
download
|
||||
})
|
||||
Logger.info(`[DownloadManager] Processing Zip download ${download.fullPath}`)
|
||||
var success = await this.zipAudiobookDir(audiobook.fullPath, download.fullPath).then(() => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error('[DownloadManager] Process Zip Failed', error)
|
||||
return false
|
||||
})
|
||||
this.sendResult(download, { success })
|
||||
}
|
||||
|
||||
zipAudiobookDir(audiobookPath, downloadPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// create a file to stream archive data to
|
||||
const output = fs.createWriteStream(downloadPath)
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
})
|
||||
|
||||
// listen for all archive data to be written
|
||||
// 'close' event is fired only when a file descriptor is involved
|
||||
output.on('close', () => {
|
||||
Logger.info(archive.pointer() + ' total bytes')
|
||||
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
|
||||
resolve()
|
||||
})
|
||||
|
||||
// This event is fired when the data source is drained no matter what was the data source.
|
||||
// It is not part of this library but rather from the NodeJS Stream API.
|
||||
// @see: https://nodejs.org/api/stream.html#stream_event_end
|
||||
output.on('end', () => {
|
||||
Logger.debug('Data has been drained')
|
||||
})
|
||||
|
||||
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
||||
archive.on('warning', function (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// log warning
|
||||
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
|
||||
} else {
|
||||
// throw error
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
// throw err
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
archive.on('error', function (err) {
|
||||
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
archive.pipe(output)
|
||||
|
||||
archive.directory(audiobookPath, false)
|
||||
|
||||
archive.finalize()
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
async processSingleAudioDownload(audiobook, download) {
|
||||
|
||||
// If changing audio file type then encoding is needed
|
||||
var audioRequiresEncode = audiobook.tracks[0].ext !== download.ext
|
||||
var shouldIncludeCover = download.includeCover && audiobook.book.cover
|
||||
var firstTrackIsM4b = audiobook.tracks[0].ext.toLowerCase() === '.m4b'
|
||||
var isOneTrack = audiobook.tracks.length === 1
|
||||
|
||||
const ffmpegInputs = []
|
||||
|
||||
if (!isOneTrack) {
|
||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||
await writeConcatFile(audiobook.tracks, concatFilePath)
|
||||
ffmpegInputs.push({
|
||||
input: concatFilePath,
|
||||
options: ['-safe 0', '-f concat']
|
||||
})
|
||||
} else {
|
||||
ffmpegInputs.push({
|
||||
input: audiobook.tracks[0].fullPath,
|
||||
options: firstTrackIsM4b ? ['-f mp4'] : []
|
||||
})
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
var ffmpegOptions = [`-loglevel ${logLevel}`]
|
||||
var ffmpegOutputOptions = []
|
||||
|
||||
if (audioRequiresEncode) {
|
||||
ffmpegOptions = ffmpegOptions.concat([
|
||||
'-map 0:a',
|
||||
'-acodec aac',
|
||||
'-ac 2',
|
||||
'-b:a 64k',
|
||||
'-id3v2_version 3'
|
||||
])
|
||||
} else {
|
||||
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
||||
|
||||
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
|
||||
ffmpegOptions.push('-c copy')
|
||||
} else {
|
||||
ffmpegOptions.push('-c:a copy')
|
||||
}
|
||||
}
|
||||
if (download.ext === '.m4b') {
|
||||
Logger.info('Concat m4b\'s use -f mp4')
|
||||
ffmpegOutputOptions.push('-f mp4')
|
||||
}
|
||||
|
||||
if (download.includeMetadata) {
|
||||
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
|
||||
await writeMetadataFile(audiobook, metadataFilePath)
|
||||
|
||||
ffmpegInputs.push({
|
||||
input: metadataFilePath
|
||||
})
|
||||
|
||||
ffmpegOptions.push('-map_metadata 1')
|
||||
}
|
||||
|
||||
if (shouldIncludeCover) {
|
||||
var _cover = audiobook.book.cover
|
||||
if (_cover.startsWith(Path.sep + 'local')) {
|
||||
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
|
||||
Logger.debug('Local cover url', _cover)
|
||||
}
|
||||
ffmpegInputs.push({
|
||||
input: _cover,
|
||||
options: ['-f image2pipe']
|
||||
})
|
||||
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2')
|
||||
ffmpegOptions.push('-map 2:v')
|
||||
}
|
||||
|
||||
var workerData = {
|
||||
inputs: ffmpegInputs,
|
||||
options: ffmpegOptions,
|
||||
outputOptions: ffmpegOutputOptions,
|
||||
output: download.fullPath,
|
||||
}
|
||||
|
||||
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
if (!download.isTimedOut) {
|
||||
this.sendResult(download, message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.error('Invalid worker message', message)
|
||||
}
|
||||
})
|
||||
this.pendingDownloads.push({
|
||||
id: download.id,
|
||||
download,
|
||||
worker
|
||||
})
|
||||
}
|
||||
|
||||
async downloadTimedOut(download) {
|
||||
Logger.info(`[DownloadManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
|
||||
|
||||
if (download.socket) {
|
||||
var downloadJson = download.toJSON()
|
||||
downloadJson.isTimedOut = true
|
||||
download.socket.emit('download_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async downloadExpired(download) {
|
||||
Logger.info(`[DownloadManager] Download ${download.id} expired`)
|
||||
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_expired', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async sendResult(download, result) {
|
||||
download.clearTimeoutTimer()
|
||||
|
||||
// Remove pending download
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_killed', download.toJSON())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_failed', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
return
|
||||
}
|
||||
|
||||
var filesize = await getFileSize(download.fullPath)
|
||||
download.setComplete(filesize)
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_ready', download.toJSON())
|
||||
}
|
||||
download.setExpirationTimer(this.downloadExpired.bind(this))
|
||||
|
||||
this.downloads.push(download)
|
||||
Logger.info(`[DownloadManager] Download Ready ${download.id}`)
|
||||
}
|
||||
|
||||
async removeDownload(download) {
|
||||
Logger.info('[DownloadManager] Removing download ' + download.id)
|
||||
|
||||
download.clearTimeoutTimer()
|
||||
download.clearExpirationTimer()
|
||||
|
||||
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
|
||||
|
||||
if (pendingDl) {
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`)
|
||||
if (pendingDl.worker) {
|
||||
try {
|
||||
pendingDl.worker.postMessage('STOP')
|
||||
} catch (error) {
|
||||
Logger.error('[DownloadManager] Error posting stop message to worker', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fs.remove(download.dirpath).then(() => {
|
||||
Logger.info('[DownloadManager] Deleted download', download.dirpath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[DownloadManager] Failed to delete download', err)
|
||||
})
|
||||
this.downloads = this.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
module.exports = DownloadManager
|
||||
@@ -30,6 +30,12 @@ class HlsController {
|
||||
var streamId = req.params.stream
|
||||
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
|
||||
|
||||
// development test stream - ignore
|
||||
if (streamId === 'test') {
|
||||
Logger.debug('Test Stream Request', streamId, req.headers, fullFilePath)
|
||||
return res.sendFile(fullFilePath)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(fullFilePath)
|
||||
if (!exists) {
|
||||
Logger.warn('File path does not exist', fullFilePath)
|
||||
|
||||
@@ -33,6 +33,11 @@ class Logger {
|
||||
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.WARN) return
|
||||
console.warn(`[${this.timestamp}] WARN:`, ...args)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const Podcast = require('podcast')
|
||||
const express = require('express')
|
||||
const ip = require('ip')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Not functional at the moment - just an idea
|
||||
class RssFeeds {
|
||||
constructor(Port, db) {
|
||||
this.Port = Port
|
||||
this.db = db
|
||||
this.feeds = {}
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
}
|
||||
|
||||
init() {
|
||||
this.router.get('/:id', this.getFeed.bind(this))
|
||||
}
|
||||
|
||||
getFeed(req, res) {
|
||||
var feed = this.feeds[req.params.id]
|
||||
if (!feed) return null
|
||||
var xml = feed.buildXml()
|
||||
res.set('Content-Type', 'text/xml')
|
||||
res.send(xml)
|
||||
}
|
||||
|
||||
openFeed(audiobook) {
|
||||
var serverAddress = 'http://' + ip.address('public', 'ipv4') + ':' + 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`,
|
||||
author: 'advplyr',
|
||||
language: 'en'
|
||||
})
|
||||
audiobook.tracks.forEach((track) => {
|
||||
feed.addItem({
|
||||
title: `Track ${track.index}`,
|
||||
description: `AudioBookshelf Audiobook Track #${track.index}`,
|
||||
url: `${serverAddress}/feeds/${feedId}?track=${track.index}`,
|
||||
author: 'advplyr'
|
||||
})
|
||||
})
|
||||
this.feeds[feedId] = feed
|
||||
return feed
|
||||
}
|
||||
}
|
||||
module.exports = RssFeeds
|
||||
@@ -1,9 +1,14 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('./Logger')
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./Audiobook')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { getAllAudiobookFiles } = require('./utils/scandir')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||
@@ -12,6 +17,8 @@ class Scanner {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
}
|
||||
|
||||
@@ -19,42 +26,299 @@ class Scanner {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
|
||||
async setAudiobookDataInos(audiobookData) {
|
||||
for (let i = 0; i < audiobookData.length; i++) {
|
||||
var abd = audiobookData[i]
|
||||
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
|
||||
if (matchingAB) {
|
||||
if (!matchingAB.ino) {
|
||||
matchingAB.ino = await getIno(matchingAB.fullPath)
|
||||
}
|
||||
abd.ino = matchingAB.ino
|
||||
} else {
|
||||
abd.ino = await getIno(abd.fullPath)
|
||||
if (!abd.ino) {
|
||||
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobookData.filter(abd => !!abd.ino)
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
|
||||
var abdFile = audiobookDataAudioFiles[i]
|
||||
var matchingFile = audiobookAudioFiles.find(af => comparePaths(af.path, abdFile.path))
|
||||
if (matchingFile) {
|
||||
if (!matchingFile.ino) {
|
||||
matchingFile.ino = await getIno(matchingFile.fullPath)
|
||||
}
|
||||
abdFile.ino = matchingFile.ino
|
||||
} else {
|
||||
abdFile.ino = await getIno(abdFile.fullPath)
|
||||
if (!abdFile.ino) {
|
||||
Logger.error('[Scanner] Invalid abdFile ino - ignoring abd audio file', abdFile.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
||||
}
|
||||
|
||||
async scanAudiobookData(audiobookData) {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
|
||||
if (existingAudiobook) {
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||
if (removedAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
audiobookData.audioFiles.forEach((file) => {
|
||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||
if (existingAudioFile) { // Audio file exists, sync paths
|
||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||
hasUpdatedAudioFiles = true
|
||||
}
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
})
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
// Scan new audio files found - sets tracks
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
|
||||
// REMOVE: No valid audio tracks
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
// NEW: Check new audiobook
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
var audiobook = new Audiobook()
|
||||
audiobook.setData(audiobookData)
|
||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
audiobook.checkUpdateMissingParts()
|
||||
audiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
return ScanResult.ADDED
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// console.log('Start scan audiobooks', this.audiobooks.map(a => a.fullPath).join(', '))
|
||||
// TEMP - fix relative file paths
|
||||
// TEMP - update ino for each audiobook
|
||||
if (this.audiobooks.length) {
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var ab = this.audiobooks[i]
|
||||
var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
|
||||
// Update ino if an audio file has the same ino as the audiobook
|
||||
var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
|
||||
if (shouldUpdateIno) {
|
||||
await ab.checkUpdateInos()
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
await this.db.updateAudiobook(ab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
|
||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
|
||||
// Set ino for each ab data as a string
|
||||
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
|
||||
var scanResults = {
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
added: 0
|
||||
}
|
||||
|
||||
// Check for removed audiobooks
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === this.audiobooks[i].ino)
|
||||
if (!dataFound) {
|
||||
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`)
|
||||
var audiobookJSON = this.audiobooks[i].toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', this.audiobooks[i].id)
|
||||
scanResults.removed++
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new and updated audiobooks
|
||||
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||
var audiobookData = audiobookDataFound[i]
|
||||
if (!audiobookData.parts.length) {
|
||||
Logger.error('No Valid Parts for Audiobook', audiobookData)
|
||||
} else {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath)
|
||||
if (existingAudiobook) {
|
||||
Logger.info('Audiobook already added', audiobookData.title)
|
||||
// Todo: Update Audiobook here
|
||||
} else {
|
||||
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
|
||||
var audiobook = new Audiobook()
|
||||
audiobook.setData(audiobookData)
|
||||
await audioFileScanner.scanParts(audiobook, audiobookData.parts)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title)
|
||||
} else {
|
||||
Logger.info('Audiobook Scanned', audiobook.title, `(${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
}
|
||||
}
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
var result = await this.scanAudiobookData(audiobookData)
|
||||
if (result === ScanResult.ADDED) scanResults.added++
|
||||
if (result === ScanResult.REMOVED) scanResults.removed++
|
||||
if (result === ScanResult.UPDATED) scanResults.updated++
|
||||
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'files',
|
||||
progress: {
|
||||
total: audiobookDataFound.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
})
|
||||
}
|
||||
})
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[SCANNER] Finished ${secondsToTimestamp(scanElapsed)}`)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
return scanResults
|
||||
}
|
||||
|
||||
async scanAudiobook(audiobookPath) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
|
||||
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
|
||||
if (!audiobookData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
audiobookData.ino = await getIno(audiobookData.fullPath)
|
||||
return this.scanAudiobookData(audiobookData)
|
||||
}
|
||||
|
||||
// Files were modified in this directory, check it out
|
||||
async checkDir(dir) {
|
||||
var exists = await fs.pathExists(dir)
|
||||
if (!exists) {
|
||||
// Audiobook was deleted, TODO: Should confirm this better
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
|
||||
if (audiobook) {
|
||||
var audiobookJSON = audiobook.toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', audiobook.id)
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
// Path inside audiobook was deleted, scan audiobook
|
||||
audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
|
||||
if (audiobook) {
|
||||
Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
|
||||
return this.scanAudiobook(audiobook.fullPath)
|
||||
}
|
||||
|
||||
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
// Check if this is a subdirectory of an audiobook
|
||||
var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
|
||||
if (audiobook) {
|
||||
Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
|
||||
return this.scanAudiobook(audiobook.fullPath)
|
||||
}
|
||||
|
||||
// Check if an audiobook is a subdirectory of this dir
|
||||
audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
|
||||
if (audiobook) {
|
||||
Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
// Must be a new audiobook
|
||||
Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
|
||||
return this.scanAudiobook(dir)
|
||||
}
|
||||
|
||||
// Array of files that may have been renamed, removed or added
|
||||
async filesChanged(filepaths) {
|
||||
if (!filepaths.length) return ScanResult.NOTHING
|
||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
|
||||
|
||||
var results = []
|
||||
for (const dir in fileGroupings) {
|
||||
Logger.debug(`[Scanner] Check dir ${dir}`)
|
||||
var fullPath = Path.join(this.AudiobookPath, dir)
|
||||
var result = await this.checkDir(fullPath)
|
||||
Logger.debug(`[Scanner] Check dir result ${result}`)
|
||||
results.push(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async fetchMetadata(id, trackIndex = 0) {
|
||||
@@ -70,6 +334,48 @@ class Scanner {
|
||||
return scanResult
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||
var found = 0
|
||||
var notFound = 0
|
||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||
var audiobook = audiobooksNeedingCover[i]
|
||||
var options = {
|
||||
titleDistance: 2,
|
||||
authorDistance: 2
|
||||
}
|
||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||
if (results.length) {
|
||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||
audiobook.book.cover = results[0]
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
found++
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
|
||||
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'covers',
|
||||
progress: {
|
||||
total: audiobooksNeedingCover.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
}
|
||||
})
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
found,
|
||||
notFound
|
||||
}
|
||||
}
|
||||
|
||||
async find(req, res) {
|
||||
var method = req.params.method
|
||||
var query = req.query
|
||||
@@ -87,7 +393,10 @@ class Scanner {
|
||||
|
||||
async findCovers(req, res) {
|
||||
var query = req.query
|
||||
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
|
||||
var options = {
|
||||
fallbackTitleOnly: !!query.fallbackTitleOnly
|
||||
}
|
||||
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null, options)
|
||||
res.json(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const express = require('express')
|
||||
const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const cookieparser = require('cookie-parser')
|
||||
const fileUpload = require('express-fileupload')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
@@ -12,15 +12,17 @@ const Db = require('./Db')
|
||||
const ApiController = require('./ApiController')
|
||||
const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
this.Port = PORT
|
||||
this.Host = '0.0.0.0'
|
||||
this.ConfigPath = CONFIG_PATH
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||
this.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
|
||||
fs.ensureDirSync(CONFIG_PATH)
|
||||
fs.ensureDirSync(METADATA_PATH)
|
||||
@@ -31,7 +33,9 @@ class Server {
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.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.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
|
||||
|
||||
this.server = null
|
||||
@@ -40,52 +44,79 @@ class Server {
|
||||
this.clients = {}
|
||||
|
||||
this.isScanning = false
|
||||
this.isScanningCovers = false
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
get settings() {
|
||||
return this.db.settings
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings
|
||||
}
|
||||
|
||||
getClientsForUser(userId) {
|
||||
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
||||
}
|
||||
|
||||
emitter(ev, data) {
|
||||
Logger.debug('EMITTER', ev)
|
||||
if (!this.io) {
|
||||
Logger.error('Invalid IO')
|
||||
return
|
||||
}
|
||||
// Logger.debug('EMITTER', ev)
|
||||
this.io.emit(ev, data)
|
||||
}
|
||||
|
||||
async fileAddedUpdated({ path, fullPath }) {
|
||||
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
|
||||
clientEmitter(userId, ev, data) {
|
||||
var clients = this.getClientsForUser(userId)
|
||||
if (!clients.length) {
|
||||
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
|
||||
}
|
||||
clients.forEach((client) => {
|
||||
if (client.socket) {
|
||||
client.socket.emit(ev, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fileRemoved({ path, fullPath }) { }
|
||||
async filesChanged(files) {
|
||||
Logger.info('[Server]', files.length, 'Files Changed')
|
||||
var result = await this.scanner.filesChanged(files)
|
||||
Logger.info('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan() {
|
||||
Logger.info('[SERVER] Starting Scan')
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start')
|
||||
await this.scanner.scan()
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan()
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete')
|
||||
Logger.info('[SERVER] Scan complete')
|
||||
this.emitter('scan_complete', { scanType: 'files', results })
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
Logger.info('[Server] Start cover scan')
|
||||
this.isScanningCovers = true
|
||||
this.emitter('scan_start', 'covers')
|
||||
var results = await this.scanner.scanCovers()
|
||||
this.isScanningCovers = false
|
||||
this.emitter('scan_complete', { scanType: 'covers', results })
|
||||
Logger.info('[Server] Cover scan complete')
|
||||
}
|
||||
|
||||
cancelScan() {
|
||||
if (!this.isScanningCovers && !this.isScanning) return
|
||||
this.scanner.cancelScan = true
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[SERVER] Init')
|
||||
Logger.info('[Server] Init')
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
await this.downloadManager.removeOrphanDownloads()
|
||||
await this.db.init()
|
||||
this.auth.init()
|
||||
|
||||
this.watcher.initWatcher()
|
||||
this.watcher.on('file_added', this.fileAddedUpdated.bind(this))
|
||||
this.watcher.on('file_removed', this.fileRemoved.bind(this))
|
||||
this.watcher.on('file_updated', this.fileAddedUpdated.bind(this))
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
@@ -101,24 +132,62 @@ class Server {
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(cookieparser('secret_family_recipe'))
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
app.use('/local', express.static(this.AudiobookPath))
|
||||
} else {
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
}
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
|
||||
app.use(express.static(this.MetadataPath))
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json())
|
||||
|
||||
// Dynamic routes are not generated on client
|
||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||
// app.use('/hls', this.hlsController.router)
|
||||
app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile('/index.html')
|
||||
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||
@@ -128,6 +197,21 @@ class Server {
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
// Used in development to set-up streams without authentication
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/test-hls', this.hlsController.router)
|
||||
app.get('/test-stream/:id', async (req, res) => {
|
||||
var uri = await this.streamManager.openTestStream(this.MetadataPath, req.params.id)
|
||||
res.send(uri)
|
||||
})
|
||||
app.get('/catalog.json', (req, res) => {
|
||||
Logger.error('Catalog request made', req.headers)
|
||||
res.json()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||
})
|
||||
@@ -150,10 +234,23 @@ class Server {
|
||||
Logger.info('[SOCKET] Socket Connected', socket.id)
|
||||
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
|
||||
// Scanning
|
||||
socket.on('scan', this.scan.bind(this))
|
||||
socket.on('scan_covers', this.scanCovers.bind(this))
|
||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||
|
||||
// Streaming
|
||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||
|
||||
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
|
||||
|
||||
// Downloading
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
||||
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
@@ -178,6 +275,14 @@ class Server {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
audiobookProgressUpdate(client, progressPayload) {
|
||||
if (!client || !client.user) {
|
||||
Logger.error('[Server] audiobookProgressUpdate invalid socket client')
|
||||
return
|
||||
}
|
||||
client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
|
||||
}
|
||||
|
||||
async authenticateSocket(socket, token) {
|
||||
var user = await this.auth.verifyToken(token)
|
||||
if (!user) {
|
||||
@@ -204,7 +309,7 @@ class Server {
|
||||
}
|
||||
|
||||
const initialPayload = {
|
||||
settings: this.settings,
|
||||
serverSettings: this.serverSettings.toJSON(),
|
||||
isScanning: this.isScanning,
|
||||
isInitialized: this.isInitialized,
|
||||
audiobookPath: this.AudiobookPath,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const Stream = require('./Stream')
|
||||
const Stream = require('./objects/Stream')
|
||||
const StreamTest = require('./test/StreamTest')
|
||||
const Logger = require('./Logger')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
@@ -90,7 +91,8 @@ class StreamManager {
|
||||
Logger.info('Close Stream Request', socket.id)
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
Logger.error('No stream for client', client.user.id)
|
||||
Logger.error('No stream for client', (client && client.user) ? client.user.username : 'No Client')
|
||||
client.socket.emit('stream_closed', 'n/a')
|
||||
return
|
||||
}
|
||||
// var streamId = client.stream.id
|
||||
@@ -100,10 +102,27 @@ class StreamManager {
|
||||
this.db.updateUserStream(client.user.id, null)
|
||||
}
|
||||
|
||||
async openTestStream(streamPath, audiobookId) {
|
||||
Logger.info('Open Stream Test Request', audiobookId)
|
||||
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
||||
var stream = new StreamTest(streamPath, audiobook)
|
||||
|
||||
stream.on('closed', () => {
|
||||
console.log('Stream closed')
|
||||
})
|
||||
|
||||
var playlistUri = await stream.generatePlaylist()
|
||||
stream.start()
|
||||
|
||||
Logger.info('Stream Playlist', playlistUri)
|
||||
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
|
||||
return playlistUri
|
||||
}
|
||||
|
||||
streamUpdate(socket, { currentTime, streamId }) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
Logger.error('No stream for client', client.user.id)
|
||||
Logger.error('No stream for client', (client && client.user) ? client.user.id : 'No Client')
|
||||
return
|
||||
}
|
||||
if (client.stream.id !== streamId) {
|
||||
@@ -115,11 +134,11 @@ class StreamManager {
|
||||
Logger.error('No User for client', client)
|
||||
return
|
||||
}
|
||||
if (!client.user.updateAudiobookProgress) {
|
||||
if (!client.user.updateAudiobookProgressFromStream) {
|
||||
Logger.error('Invalid User for client', client)
|
||||
return
|
||||
}
|
||||
client.user.updateAudiobookProgress(client.stream)
|
||||
client.user.updateAudiobookProgressFromStream(client.stream)
|
||||
this.db.updateEntity('user', client.user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
class User {
|
||||
constructor(user) {
|
||||
this.id = null
|
||||
this.username = null
|
||||
this.pash = null
|
||||
this.type = null
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
pash: this.pash,
|
||||
type: this.type,
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooks,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
return {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
type: this.type,
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooks,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(user) {
|
||||
this.id = user.id
|
||||
this.username = user.username
|
||||
this.pash = user.pash
|
||||
this.type = user.type
|
||||
this.stream = user.stream
|
||||
this.token = user.token
|
||||
this.audiobooks = user.audiobooks || null
|
||||
this.createdAt = user.createdAt
|
||||
}
|
||||
|
||||
updateAudiobookProgress(stream) {
|
||||
if (!this.audiobooks) this.audiobooks = {}
|
||||
if (!this.audiobooks[stream.audiobookId]) {
|
||||
this.audiobooks[stream.audiobookId] = {
|
||||
audiobookId: stream.audiobookId,
|
||||
totalDuration: stream.totalDuration,
|
||||
startedAt: Date.now()
|
||||
}
|
||||
}
|
||||
this.audiobooks[stream.audiobookId].lastUpdate = Date.now()
|
||||
this.audiobooks[stream.audiobookId].progress = stream.clientProgress
|
||||
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
|
||||
}
|
||||
|
||||
resetAudiobookProgress(audiobookId) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return false
|
||||
}
|
||||
delete this.audiobooks[audiobookId]
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = User
|
||||
@@ -1,6 +1,7 @@
|
||||
var EventEmitter = require('events')
|
||||
var Logger = require('./Logger')
|
||||
var chokidar = require('chokidar')
|
||||
const Path = require('path')
|
||||
const EventEmitter = require('events')
|
||||
const Watcher = require('watcher')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor(audiobookPath) {
|
||||
@@ -8,64 +9,103 @@ class FolderWatcher extends EventEmitter {
|
||||
this.AudiobookPath = audiobookPath
|
||||
this.folderMap = {}
|
||||
this.watcher = null
|
||||
|
||||
this.pendingFiles = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
}
|
||||
|
||||
initWatcher() {
|
||||
try {
|
||||
Logger.info('[WATCHER] Initializing..')
|
||||
this.watcher = chokidar.watch(this.AudiobookPath, {
|
||||
ignoreInitial: true,
|
||||
Logger.info('[FolderWatcher] Initializing..')
|
||||
this.watcher = new Watcher(this.AudiobookPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 2500,
|
||||
pollInterval: 500
|
||||
}
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
})
|
||||
this.watcher
|
||||
.on('add', (path) => {
|
||||
this.onNewFile(path)
|
||||
}).on('change', (path) => {
|
||||
this.onFileUpdated(path)
|
||||
// This is triggered from metadata changes, not what we want
|
||||
// this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`Watcher error: ${error}`)
|
||||
Logger.error(`[FolderWatcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info('[WATCHER] Ready')
|
||||
Logger.info('[FolderWatcher] Ready')
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error('Chokidar watcher failed', error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
close() {
|
||||
return this.watcher.close()
|
||||
}
|
||||
|
||||
onNewFile(path) {
|
||||
Logger.info('FolderWatcher: New File', path)
|
||||
this.emit('file_added', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
})
|
||||
// After [pendingBatchDelay] seconds emit batch
|
||||
async onNewFile(path) {
|
||||
if (this.pendingFiles.includes(path)) return
|
||||
|
||||
Logger.debug('FolderWatcher: New File', path)
|
||||
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
|
||||
onFileRemoved(path) {
|
||||
Logger.info('FolderWatcher: File Removed', path)
|
||||
this.emit('file_removed', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
})
|
||||
Logger.debug('[FolderWatcher] File Removed', path)
|
||||
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
|
||||
onFileUpdated(path) {
|
||||
Logger.info('FolderWatcher: Updated File', path)
|
||||
this.emit('file_updated', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
})
|
||||
Logger.debug('[FolderWatcher] Updated File', path)
|
||||
}
|
||||
|
||||
onRename(pathFrom, pathTo) {
|
||||
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
|
||||
var dir = Path.dirname(pathTo)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(pathTo)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
}
|
||||
module.exports = FolderWatcher
|
||||
@@ -0,0 +1,158 @@
|
||||
class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
this.filename = null
|
||||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
this.trackNumFromMeta = null
|
||||
this.trackNumFromFilename = null
|
||||
|
||||
this.format = null
|
||||
this.duration = null
|
||||
this.size = null
|
||||
this.bitRate = null
|
||||
this.language = null
|
||||
this.codec = null
|
||||
this.timeBase = null
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
this.chapters = []
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
this.invalid = false
|
||||
this.exclude = false
|
||||
this.error = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
filename: this.filename,
|
||||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
trackNumFromMeta: this.trackNumFromMeta,
|
||||
trackNumFromFilename: this.trackNumFromFilename,
|
||||
manuallyVerified: !!this.manuallyVerified,
|
||||
invalid: !!this.invalid,
|
||||
exclude: !!this.exclude,
|
||||
error: this.error || null,
|
||||
format: this.format,
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.index = data.index
|
||||
this.ino = data.ino
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = data.addedAt
|
||||
this.manuallyVerified = !!data.manuallyVerified
|
||||
this.invalid = !!data.invalid
|
||||
this.exclude = !!data.exclude
|
||||
this.error = data.error || null
|
||||
|
||||
this.trackNumFromMeta = data.trackNumFromMeta || null
|
||||
this.trackNumFromFilename = data.trackNumFromFilename || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.timeBase = data.timeBase
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
this.chapters = data.chapters
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
this.tagGenre = data.tagGenre
|
||||
this.tagTitle = data.tagTitle
|
||||
this.tagTrack = data.tagTrack
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.index = data.index || null
|
||||
this.ino = data.ino || null
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
|
||||
this.trackNumFromMeta = data.trackNumFromMeta || null
|
||||
this.trackNumFromFilename = data.trackNumFromFilename || null
|
||||
|
||||
this.manuallyVerified = !!data.manuallyVerified
|
||||
this.invalid = !!data.invalid
|
||||
this.exclude = !!data.exclude
|
||||
this.error = data.error || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.bitRate = data.bit_rate || null
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.timeBase = data.time_base
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
this.chapters = data.chapters || []
|
||||
|
||||
this.tagAlbum = data.file_tag_album || null
|
||||
this.tagArtist = data.file_tag_artist || null
|
||||
this.tagGenre = data.file_tag_genre || null
|
||||
this.tagTitle = data.file_tag_title || null
|
||||
this.tagTrack = data.file_tag_track || null
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new AudioFile(this.toJSON())
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
keysToSync.forEach((key) => {
|
||||
if (newFile[key] !== undefined && newFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = newFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioFile
|
||||
@@ -1,8 +1,10 @@
|
||||
var { bytesPretty } = require('./utils/fileUtils')
|
||||
var { bytesPretty } = require('../utils/fileUtils')
|
||||
|
||||
class AudioTrack {
|
||||
constructor(audioTrack = null) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.ext = null
|
||||
@@ -31,6 +33,8 @@ class AudioTrack {
|
||||
|
||||
construct(audioTrack) {
|
||||
this.index = audioTrack.index
|
||||
this.ino = audioTrack.ino || null
|
||||
|
||||
this.path = audioTrack.path
|
||||
this.fullPath = audioTrack.fullPath
|
||||
this.ext = audioTrack.ext
|
||||
@@ -45,6 +49,12 @@ class AudioTrack {
|
||||
this.timeBase = audioTrack.timeBase
|
||||
this.channels = audioTrack.channels
|
||||
this.channelLayout = audioTrack.channelLayout
|
||||
|
||||
this.tagAlbum = audioTrack.tagAlbum
|
||||
this.tagArtist = audioTrack.tagArtist
|
||||
this.tagGenre = audioTrack.tagGenre
|
||||
this.tagTitle = audioTrack.tagTitle
|
||||
this.tagTrack = audioTrack.tagTrack
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -54,6 +64,7 @@ class AudioTrack {
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
ext: this.ext,
|
||||
@@ -65,12 +76,19 @@ class AudioTrack {
|
||||
language: this.language,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
setData(probeData) {
|
||||
this.index = probeData.index
|
||||
this.ino = probeData.ino || null
|
||||
|
||||
this.path = probeData.path
|
||||
this.fullPath = probeData.fullPath
|
||||
this.ext = probeData.ext
|
||||
@@ -79,12 +97,12 @@ class AudioTrack {
|
||||
this.format = probeData.format
|
||||
this.duration = probeData.duration
|
||||
this.size = probeData.size
|
||||
this.bitRate = probeData.bit_rate
|
||||
this.bitRate = probeData.bitRate
|
||||
this.language = probeData.language
|
||||
this.codec = probeData.codec
|
||||
this.timeBase = probeData.time_base
|
||||
this.timeBase = probeData.timeBase
|
||||
this.channels = probeData.channels
|
||||
this.channelLayout = probeData.channel_layout
|
||||
this.channelLayout = probeData.channelLayout
|
||||
|
||||
this.tagAlbum = probeData.file_tag_album || null
|
||||
this.tagArtist = probeData.file_tag_artist || null
|
||||
@@ -92,5 +110,17 @@ class AudioTrack {
|
||||
this.tagTitle = probeData.file_tag_title || null
|
||||
this.tagTrack = probeData.file_tag_track || null
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
keysToSync.forEach((key) => {
|
||||
if (newFile[key] !== undefined && newFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = newFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioTrack
|
||||
@@ -0,0 +1,460 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
const AudioFile = require('./AudioFile')
|
||||
const AudiobookFile = require('./AudiobookFile')
|
||||
|
||||
class Audiobook {
|
||||
constructor(audiobook = null) {
|
||||
this.id = null
|
||||
this.ino = null // Inode
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
|
||||
this.addedAt = null
|
||||
this.lastUpdate = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
|
||||
this.audioFiles = []
|
||||
this.otherFiles = []
|
||||
|
||||
this.tags = []
|
||||
this.book = null
|
||||
this.chapters = []
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
}
|
||||
|
||||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.ino = audiobook.ino || null
|
||||
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
||||
this.missingParts = audiobook.missingParts
|
||||
|
||||
this.audioFiles = audiobook.audioFiles.map(file => new AudioFile(file))
|
||||
this.otherFiles = audiobook.otherFiles.map(file => new AudiobookFile(file))
|
||||
|
||||
this.tags = audiobook.tags
|
||||
if (audiobook.book) {
|
||||
this.book = new Book(audiobook.book)
|
||||
}
|
||||
if (audiobook.chapters) {
|
||||
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
}
|
||||
|
||||
get author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
|
||||
get cover() {
|
||||
return this.book ? this.book.cover : ''
|
||||
}
|
||||
|
||||
get authorLF() {
|
||||
return this.book ? this.book.authorLF : null
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
|
||||
get totalDuration() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.duration)
|
||||
return total
|
||||
}
|
||||
|
||||
get totalSize() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.size)
|
||||
return total
|
||||
}
|
||||
|
||||
get sizePretty() {
|
||||
return bytesPretty(this.totalSize)
|
||||
}
|
||||
|
||||
get durationPretty() {
|
||||
return elapsedPretty(this.totalDuration)
|
||||
}
|
||||
|
||||
get invalidParts() {
|
||||
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
|
||||
tracksToJSON() {
|
||||
if (!this.tracks || !this.tracks.length) return []
|
||||
return this.tracks.map(t => t.toJSON())
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
missingParts: this.missingParts,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
chapters: this.chapters || []
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
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,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || []
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.totalDuration,
|
||||
durationPretty: this.durationPretty,
|
||||
size: this.totalSize,
|
||||
sizePretty: this.sizePretty,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || []
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner had a bug that was saving a file path as the audiobook path.
|
||||
// audiobook path should be a directory.
|
||||
// fixing this before a scan prevents audiobooks being removed and re-added
|
||||
fixRelativePath(abRootPath) {
|
||||
var pathExt = Path.extname(this.path)
|
||||
if (pathExt) {
|
||||
this.path = Path.dirname(this.path)
|
||||
this.fullPath = Path.join(abRootPath, this.path)
|
||||
Logger.warn('Audiobook path has extname', pathExt, 'fixed path:', this.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Update was made to add ino values, ensure they are set
|
||||
async checkUpdateInos() {
|
||||
var hasUpdates = false
|
||||
if (!this.ino) {
|
||||
this.ino = await getIno(this.fullPath)
|
||||
hasUpdates = true
|
||||
}
|
||||
for (let i = 0; i < this.audioFiles.length; i++) {
|
||||
var af = this.audioFiles[i]
|
||||
if (!af.ino || af.ino === this.ino) {
|
||||
af.ino = await getIno(af.fullPath)
|
||||
if (!af.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
|
||||
} else {
|
||||
var track = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (track) {
|
||||
track.ino = af.ino
|
||||
}
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.ino = data.ino || null
|
||||
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
this.lastUpdate = this.addedAt
|
||||
|
||||
if (data.otherFiles) {
|
||||
data.otherFiles.forEach((file) => {
|
||||
this.addOtherFile(file)
|
||||
})
|
||||
}
|
||||
|
||||
this.setBook(data)
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
||||
addTrack(trackData) {
|
||||
var track = new AudioTrack()
|
||||
track.setData(trackData)
|
||||
this.tracks.push(track)
|
||||
return track
|
||||
}
|
||||
|
||||
addAudioFile(audioFileData) {
|
||||
var audioFile = new AudioFile()
|
||||
audioFile.setData(audioFileData)
|
||||
this.audioFiles.push(audioFile)
|
||||
return audioFile
|
||||
}
|
||||
|
||||
addOtherFile(fileData) {
|
||||
var file = new AudiobookFile()
|
||||
file.setData(fileData)
|
||||
this.otherFiles.push(file)
|
||||
return file
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
||||
this.tags = payload.tags
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.book) {
|
||||
if (!this.book) {
|
||||
this.setBook(payload.book)
|
||||
hasUpdates = true
|
||||
} else if (this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateAudioTracks(orderedFileData) {
|
||||
var index = 1
|
||||
this.audioFiles = orderedFileData.map((fileData) => {
|
||||
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
|
||||
audioFile.manuallyVerified = true
|
||||
audioFile.invalid = false
|
||||
audioFile.error = null
|
||||
if (fileData.exclude !== undefined) {
|
||||
audioFile.exclude = !!fileData.exclude
|
||||
}
|
||||
if (audioFile.exclude) {
|
||||
audioFile.index = -1
|
||||
} else {
|
||||
audioFile.index = index++
|
||||
}
|
||||
return audioFile
|
||||
})
|
||||
|
||||
this.audioFiles.sort((a, b) => a.index - b.index)
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
this.audioFiles.forEach((file) => {
|
||||
if (!file.exclude) {
|
||||
this.addTrack(file)
|
||||
}
|
||||
})
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
removeAudioFile(audioFile) {
|
||||
this.tracks = this.tracks.filter(t => t.ino !== audioFile.ino)
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
||||
}
|
||||
|
||||
checkUpdateMissingParts() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
var current_index = 1
|
||||
var missingParts = []
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var _track = this.tracks[i]
|
||||
if (_track.index > current_index) {
|
||||
var num_parts_missing = _track.index - current_index
|
||||
for (let x = 0; x < num_parts_missing; x++) {
|
||||
missingParts.push(current_index + x)
|
||||
}
|
||||
}
|
||||
current_index = _track.index + 1
|
||||
}
|
||||
|
||||
this.missingParts = missingParts
|
||||
|
||||
var newMissingParts = (this.missingParts || []).join(',') || ''
|
||||
var wasUpdated = newMissingParts !== currMissingParts
|
||||
if (wasUpdated && this.missingParts.length) {
|
||||
Logger.info(`[Audiobook] "${this.title}" has ${missingParts.length} missing parts`)
|
||||
}
|
||||
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
syncOtherFiles(newOtherFiles) {
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
}
|
||||
})
|
||||
|
||||
var hasUpdates = currOtherFileNum !== this.otherFiles.length
|
||||
|
||||
// Check if cover was a local image and that it still exists
|
||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, this.book.cover.substr('/local/'.length)))
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
||||
this.book.cover = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no cover set and image file exists then use it
|
||||
if (!this.book.cover && imageFiles.length) {
|
||||
this.book.cover = Path.join('/local', imageFiles[0].path)
|
||||
Logger.info(`[Audiobook] Local cover was set | "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncAudioFile(audioFile, fileScanData) {
|
||||
var hasUpdates = audioFile.syncFile(fileScanData)
|
||||
var track = this.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (track && track.syncFile(fileScanData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncPaths(audiobookData) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath']
|
||||
keysToSync.forEach((key) => {
|
||||
if (audiobookData[key] !== undefined && audiobookData[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = audiobookData[key]
|
||||
}
|
||||
})
|
||||
if (hasUpdates) {
|
||||
this.book.syncPathsUpdated(audiobookData)
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
isSearchMatch(search) {
|
||||
return this.book.isSearchMatch(search.toLowerCase().trim())
|
||||
}
|
||||
|
||||
getAudioFileByIno(ino) {
|
||||
return this.audioFiles.find(af => af.ino === ino)
|
||||
}
|
||||
|
||||
setChapters() {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
if (includedAudioFiles[0].chapters) {
|
||||
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
|
||||
}
|
||||
} else {
|
||||
this.chapters = []
|
||||
var currChapterId = 0
|
||||
var currStartTime = 0
|
||||
includedAudioFiles.forEach((file) => {
|
||||
// If audio file has chapters use chapters
|
||||
if (file.chapters && file.chapters.length) {
|
||||
file.chapters.forEach((chapter) => {
|
||||
var chapterDuration = chapter.end - chapter.start
|
||||
if (chapterDuration > 0) {
|
||||
var title = `Chapter ${currChapterId}`
|
||||
if (chapter.title) {
|
||||
title += ` (${chapter.title})`
|
||||
}
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + chapterDuration,
|
||||
title
|
||||
})
|
||||
currStartTime += chapterDuration
|
||||
}
|
||||
})
|
||||
} else if (file.duration) {
|
||||
// Otherwise just use track has chapter
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title: `Chapter ${currChapterId}`
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -0,0 +1,48 @@
|
||||
class AudiobookFile {
|
||||
constructor(data) {
|
||||
this.ino = null
|
||||
this.filetype = null
|
||||
this.filename = null
|
||||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
ino: this.ino || null,
|
||||
filetype: this.filetype,
|
||||
filename: this.filename,
|
||||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.ino = data.ino || null
|
||||
this.filetype = data.filetype
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = data.addedAt
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.ino = data.ino || null
|
||||
this.filetype = data.filetype
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = AudiobookFile
|
||||
@@ -0,0 +1,91 @@
|
||||
class AudiobookProgress {
|
||||
constructor(progress) {
|
||||
this.audiobookId = null
|
||||
this.totalDuration = null // seconds
|
||||
this.progress = null // 0 to 1
|
||||
this.currentTime = null // seconds
|
||||
this.isRead = false
|
||||
this.lastUpdate = null
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
|
||||
if (progress) {
|
||||
this.construct(progress)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
audiobookId: this.audiobookId,
|
||||
totalDuration: this.totalDuration,
|
||||
progress: this.progress,
|
||||
currentTime: this.currentTime,
|
||||
isRead: this.isRead,
|
||||
lastUpdate: this.lastUpdate,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(progress) {
|
||||
this.audiobookId = progress.audiobookId
|
||||
this.totalDuration = progress.totalDuration
|
||||
this.progress = progress.progress
|
||||
this.currentTime = progress.currentTime
|
||||
this.isRead = !!progress.isRead
|
||||
this.lastUpdate = progress.lastUpdate
|
||||
this.startedAt = progress.startedAt
|
||||
this.finishedAt = progress.finishedAt || null
|
||||
}
|
||||
|
||||
updateFromStream(stream) {
|
||||
this.audiobookId = stream.audiobookId
|
||||
this.totalDuration = stream.totalDuration
|
||||
this.progress = stream.clientProgress
|
||||
this.currentTime = stream.clientCurrentTime
|
||||
this.lastUpdate = Date.now()
|
||||
|
||||
if (!this.startedAt) {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
} else {
|
||||
this.isRead = false
|
||||
this.finishedAt = null
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (payload[key] !== this[key]) {
|
||||
if (key === 'isRead') {
|
||||
if (!payload[key]) { // Updating to Not Read - Reset progress and current time
|
||||
this.finishedAt = null
|
||||
this.progress = 0
|
||||
this.currentTime = 0
|
||||
} else { // Updating to Read
|
||||
if (!this.finishedAt) this.finishedAt = Date.now()
|
||||
this.progress = 1
|
||||
}
|
||||
}
|
||||
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
if (!this.startedAt) {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudiobookProgress
|
||||
@@ -0,0 +1,167 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
|
||||
class Book {
|
||||
constructor(book = null) {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.subtitle = null
|
||||
this.author = null
|
||||
this.authorFL = null
|
||||
this.authorLF = null
|
||||
this.narrarator = null
|
||||
this.series = null
|
||||
this.volumeNumber = null
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.genres = []
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
}
|
||||
}
|
||||
|
||||
get _title() { return this.title || '' }
|
||||
get _subtitle() { return this.subtitle || '' }
|
||||
get _narrarator() { return this.narrarator || '' }
|
||||
get _author() { return this.author || '' }
|
||||
get _series() { return this.series || '' }
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.subtitle = book.subtitle || null
|
||||
this.author = book.author
|
||||
this.authorFL = book.authorFL || null
|
||||
this.authorLF = book.authorLF || null
|
||||
this.narrarator = book.narrarator || null
|
||||
this.series = book.series
|
||||
this.volumeNumber = book.volumeNumber || null
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.genres = book.genres
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
subtitle: this.subtitle,
|
||||
author: this.author,
|
||||
authorFL: this.authorFL,
|
||||
authorLF: this.authorLF,
|
||||
narrarator: this.narrarator,
|
||||
series: this.series,
|
||||
volumeNumber: this.volumeNumber,
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
genres: this.genres
|
||||
}
|
||||
}
|
||||
|
||||
setParseAuthor(author) {
|
||||
if (!author) {
|
||||
var hasUpdated = this.authorFL || this.authorLF
|
||||
this.authorFL = null
|
||||
this.authorLF = null
|
||||
return hasUpdated
|
||||
}
|
||||
try {
|
||||
var { authorLF, authorFL } = parseAuthors(author)
|
||||
var hasUpdated = authorLF !== this.authorLF || authorFL !== this.authorFL
|
||||
this.authorFL = authorFL || null
|
||||
this.authorLF = authorLF || null
|
||||
return hasUpdated
|
||||
} catch (err) {
|
||||
Logger.error('[Book] Parse authors failed', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.subtitle = data.subtitle || null
|
||||
this.author = data.author || null
|
||||
this.narrarator = data.narrarator || null
|
||||
this.series = data.series || null
|
||||
this.volumeNumber = data.volumeNumber || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.genres = data.genres || []
|
||||
|
||||
if (data.author) {
|
||||
this.setParseAuthor(this.author)
|
||||
}
|
||||
|
||||
// Use first image file as cover
|
||||
if (data.otherFiles && data.otherFiles.length) {
|
||||
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
this.cover = Path.normalize(Path.join('/local', imageFile.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.cover) {
|
||||
// If updating to local cover then normalize path
|
||||
if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
|
||||
payload.cover = Path.normalize(payload.cover)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in payload) {
|
||||
if (payload[key] === undefined) continue;
|
||||
|
||||
if (key === 'genres') {
|
||||
if (payload['genres'] === null && this.genres !== null) {
|
||||
this.genres = []
|
||||
hasUpdates = true
|
||||
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
|
||||
this.genres = payload['genres']
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (key === 'author') {
|
||||
if (this.author !== payload.author) {
|
||||
this.author = payload.author || null
|
||||
hasUpdates = true
|
||||
}
|
||||
if (this.setParseAuthor(this.author)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||
syncPathsUpdated(audiobookData) {
|
||||
var keysToSync = ['author', 'title', 'series', 'publishYear']
|
||||
var syncPayload = {}
|
||||
keysToSync.forEach((key) => {
|
||||
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
|
||||
})
|
||||
if (!Object.keys(syncPayload).length) return false
|
||||
return this.update(syncPayload)
|
||||
}
|
||||
|
||||
isSearchMatch(search) {
|
||||
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
@@ -0,0 +1,139 @@
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
|
||||
const DEFAULT_TIMEOUT = 1000 * 60 * 15 // 15 minutes
|
||||
class Download {
|
||||
constructor(download) {
|
||||
this.id = null
|
||||
this.audiobookId = null
|
||||
this.type = null
|
||||
this.options = {}
|
||||
|
||||
this.dirpath = null
|
||||
this.fullPath = null
|
||||
this.ext = null
|
||||
this.filename = null
|
||||
this.size = 0
|
||||
|
||||
this.userId = null
|
||||
this.socket = null // Socket to notify when complete
|
||||
this.isReady = false
|
||||
this.isTimedOut = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.expiresAt = null
|
||||
|
||||
this.expirationTimeMs = 0
|
||||
this.timeoutTimeMs = 0
|
||||
|
||||
this.timeoutTimer = null
|
||||
this.expirationTimer = null
|
||||
|
||||
if (download) {
|
||||
this.construct(download)
|
||||
}
|
||||
}
|
||||
|
||||
get includeMetadata() {
|
||||
return !!this.options.includeMetadata
|
||||
}
|
||||
|
||||
get includeCover() {
|
||||
return !!this.options.includeCover
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
if (this.ext === '.mp3' || this.ext === '.m4b' || this.ext === '.m4a') {
|
||||
return 'audio/mpeg'
|
||||
} else if (this.ext === '.mp4') {
|
||||
return 'audio/mp4'
|
||||
} else if (this.ext === '.ogg') {
|
||||
return 'audio/ogg'
|
||||
} else if (this.ext === '.aac' || this.ext === '.m4p') {
|
||||
return 'audio/aac'
|
||||
}
|
||||
return 'audio/mpeg'
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
audiobookId: this.audiobookId,
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
dirpath: this.dirpath,
|
||||
fullPath: this.fullPath,
|
||||
ext: this.ext,
|
||||
filename: this.filename,
|
||||
size: this.size,
|
||||
userId: this.userId,
|
||||
isReady: this.isReady,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
expirationSeconds: this.expirationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
construct(download) {
|
||||
this.id = download.id
|
||||
this.audiobookId = download.audiobookId
|
||||
this.type = download.type
|
||||
this.options = { ...download.options }
|
||||
|
||||
this.dirpath = download.dirpath
|
||||
this.fullPath = download.fullPath
|
||||
this.ext = download.ext
|
||||
this.filename = download.filename
|
||||
this.size = download.size || 0
|
||||
|
||||
this.userId = download.userId
|
||||
this.socket = download.socket || null
|
||||
this.isReady = !!download.isReady
|
||||
|
||||
this.startedAt = download.startedAt
|
||||
this.finishedAt = download.finishedAt || null
|
||||
|
||||
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
|
||||
this.timeoutTimeMs = download.timeoutTimeMs || DEFAULT_TIMEOUT
|
||||
|
||||
this.expiresAt = download.expiresAt || null
|
||||
}
|
||||
|
||||
setData(downloadData) {
|
||||
downloadData.startedAt = Date.now()
|
||||
downloadData.isProcessing = true
|
||||
this.construct(downloadData)
|
||||
}
|
||||
|
||||
setComplete(fileSize) {
|
||||
this.finishedAt = Date.now()
|
||||
this.size = fileSize
|
||||
this.isReady = true
|
||||
this.expiresAt = this.finishedAt + this.expirationTimeMs
|
||||
}
|
||||
|
||||
setExpirationTimer(callback) {
|
||||
this.expirationTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
callback(this)
|
||||
}
|
||||
}, this.expirationTimeMs)
|
||||
}
|
||||
|
||||
setTimeoutTimer(callback) {
|
||||
this.timeoutTimer = setTimeout(() => {
|
||||
if (callback) {
|
||||
this.isTimedOut = true
|
||||
callback(this)
|
||||
}
|
||||
}, this.timeoutTimeMs)
|
||||
}
|
||||
|
||||
clearTimeoutTimer() {
|
||||
clearTimeout(this.timeoutTimer)
|
||||
}
|
||||
|
||||
clearExpirationTimer() {
|
||||
clearTimeout(this.expirationTimer)
|
||||
}
|
||||
}
|
||||
module.exports = Download
|
||||
@@ -0,0 +1,39 @@
|
||||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
this.scannerParseSubtitle = false
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
}
|
||||
}
|
||||
|
||||
construct(settings) {
|
||||
this.autoTagNew = settings.autoTagNew
|
||||
this.newTagExpireDays = settings.newTagExpireDays
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
autoTagNew: this.autoTagNew,
|
||||
newTagExpireDays: this.newTagExpireDays,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (this[key] !== payload[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = ServerSettings
|
||||
@@ -2,9 +2,10 @@ const Ffmpeg = require('fluent-ffmpeg')
|
||||
const EventEmitter = require('events')
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const { secondsToTimestamp } = require('../utils/fileUtils')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
|
||||
|
||||
class Stream extends EventEmitter {
|
||||
constructor(streamPath, client, audiobook) {
|
||||
@@ -19,7 +20,7 @@ class Stream extends EventEmitter {
|
||||
this.streamPath = Path.join(streamPath, this.id)
|
||||
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
|
||||
this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')
|
||||
this.startTime = 0
|
||||
|
||||
this.ffmpeg = null
|
||||
@@ -42,6 +43,10 @@ class Stream extends EventEmitter {
|
||||
return this.audiobook.id
|
||||
}
|
||||
|
||||
get audiobookTitle() {
|
||||
return this.audiobook ? this.audiobook.title : null
|
||||
}
|
||||
|
||||
get totalDuration() {
|
||||
return this.audiobook.totalDuration
|
||||
}
|
||||
@@ -129,7 +134,6 @@ class Stream extends EventEmitter {
|
||||
async generatePlaylist() {
|
||||
fs.ensureDirSync(this.streamPath)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
|
||||
console.log('Playlist generated')
|
||||
return this.clientPlaylistUri
|
||||
}
|
||||
|
||||
@@ -191,7 +195,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
this.socket.emit('stream_progress', {
|
||||
stream: this.id,
|
||||
percentCreated: perc,
|
||||
percent: perc,
|
||||
chunks,
|
||||
numSegments: this.numSegments
|
||||
})
|
||||
@@ -201,40 +205,28 @@ class Stream extends EventEmitter {
|
||||
}
|
||||
|
||||
startLoop() {
|
||||
this.socket.emit('stream_progress', { chunks: [], numSegments: 0 })
|
||||
this.loop = setInterval(() => {
|
||||
// Logger.info(`[Stream] ${this.audiobookTitle} (${this.id}) Start Loop`)
|
||||
this.socket.emit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })
|
||||
|
||||
clearInterval(this.loop)
|
||||
var intervalId = setInterval(() => {
|
||||
if (!this.isTranscodeComplete) {
|
||||
this.checkFiles()
|
||||
} else {
|
||||
Logger.info(`[Stream] ${this.audiobookTitle} sending stream_ready`)
|
||||
this.socket.emit('stream_ready')
|
||||
clearTimeout(this.loop)
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
|
||||
this.loop = intervalId
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
|
||||
|
||||
this.ffmpeg = Ffmpeg()
|
||||
var currTrackEnd = 0
|
||||
var startingTrack = this.tracks.find(t => {
|
||||
currTrackEnd += t.duration
|
||||
return this.startTime < currTrackEnd
|
||||
})
|
||||
var trackStartTime = currTrackEnd - startingTrack.duration
|
||||
|
||||
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
|
||||
var trackPaths = tracksToInclude.map(t => {
|
||||
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||
return line
|
||||
})
|
||||
var inputstr = trackPaths.join('\n\n')
|
||||
await fs.writeFile(this.concatFilesPath, inputstr)
|
||||
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
|
||||
|
||||
this.ffmpeg.addInput(this.concatFilesPath)
|
||||
this.ffmpeg.inputFormat('concat')
|
||||
@@ -247,8 +239,9 @@ class Stream extends EventEmitter {
|
||||
this.ffmpeg.inputOption('-noaccurate_seek')
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
this.ffmpeg.addOption([
|
||||
'-loglevel warning',
|
||||
`-loglevel ${logLevel}`,
|
||||
'-map 0:a',
|
||||
'-c:a copy'
|
||||
])
|
||||
@@ -267,17 +260,20 @@ class Stream extends EventEmitter {
|
||||
])
|
||||
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||
this.ffmpeg.output(this.fakePlaylistPath)
|
||||
this.ffmpeg.output(this.finalPlaylistPath)
|
||||
|
||||
this.ffmpeg.on('start', (command) => {
|
||||
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
|
||||
Logger.info('')
|
||||
if (this.isResetting) {
|
||||
setTimeout(() => {
|
||||
Logger.info('[STREAM] Clearing isResetting')
|
||||
this.isResetting = false
|
||||
this.startLoop()
|
||||
}, 500)
|
||||
} else {
|
||||
this.startLoop()
|
||||
}
|
||||
this.startLoop()
|
||||
})
|
||||
|
||||
this.ffmpeg.on('stderr', (stdErrline) => {
|
||||
@@ -0,0 +1,208 @@
|
||||
const AudiobookProgress = require('./AudiobookProgress')
|
||||
|
||||
class User {
|
||||
constructor(user) {
|
||||
this.id = null
|
||||
this.username = null
|
||||
this.pash = null
|
||||
this.type = null
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
this.settings = {}
|
||||
this.permissions = {}
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
}
|
||||
}
|
||||
|
||||
get isRoot() {
|
||||
return this.type === 'root'
|
||||
}
|
||||
get canDelete() {
|
||||
return !!this.permissions.delete && this.isActive
|
||||
}
|
||||
get canUpdate() {
|
||||
return !!this.permissions.update && this.isActive
|
||||
}
|
||||
get canDownload() {
|
||||
return !!this.permissions.download && this.isActive
|
||||
}
|
||||
|
||||
getDefaultUserSettings() {
|
||||
return {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
bookshelfCoverSize: 120
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultUserPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
delete: this.id === 'root'
|
||||
}
|
||||
}
|
||||
|
||||
audiobooksToJSON() {
|
||||
if (!this.audiobooks) return null
|
||||
var _map = {}
|
||||
for (const key in this.audiobooks) {
|
||||
if (this.audiobooks[key]) {
|
||||
_map[key] = this.audiobooks[key].toJSON()
|
||||
}
|
||||
}
|
||||
return _map
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
pash: this.pash,
|
||||
type: this.type,
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
}
|
||||
}
|
||||
|
||||
toJSONForBrowser() {
|
||||
return {
|
||||
id: this.id,
|
||||
username: this.username,
|
||||
type: this.type,
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
}
|
||||
}
|
||||
|
||||
construct(user) {
|
||||
this.id = user.id
|
||||
this.username = user.username
|
||||
this.pash = user.pash
|
||||
this.type = user.type
|
||||
this.stream = user.stream || null
|
||||
this.token = user.token
|
||||
if (user.audiobooks) {
|
||||
this.audiobooks = {}
|
||||
for (const key in user.audiobooks) {
|
||||
if (user.audiobooks[key]) {
|
||||
this.audiobooks[key] = new AudiobookProgress(user.audiobooks[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
// Update the following keys:
|
||||
const keysToCheck = ['pash', 'type', 'username', 'isActive']
|
||||
keysToCheck.forEach((key) => {
|
||||
if (payload[key] !== undefined) {
|
||||
if (key === 'isActive' || payload[key]) { // pash, type, username must evaluate to true (cannot be null or empty)
|
||||
if (payload[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = payload[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// And update permissions
|
||||
if (payload.permissions) {
|
||||
for (const key in payload.permissions) {
|
||||
if (payload.permissions[key] !== this.permissions[key]) {
|
||||
hasUpdates = true
|
||||
this.permissions[key] = payload.permissions[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateAudiobookProgressFromStream(stream) {
|
||||
if (!this.audiobooks) this.audiobooks = {}
|
||||
if (!this.audiobooks[stream.audiobookId]) {
|
||||
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
|
||||
}
|
||||
this.audiobooks[stream.audiobookId].updateFromStream(stream)
|
||||
}
|
||||
|
||||
updateAudiobookProgress(audiobookId, updatePayload) {
|
||||
if (!this.audiobooks) this.audiobooks = {}
|
||||
if (!this.audiobooks[audiobookId]) {
|
||||
this.audiobooks[audiobookId] = new AudiobookProgress()
|
||||
this.audiobooks[audiobookId].audiobookId = audiobookId
|
||||
}
|
||||
return this.audiobooks[audiobookId].update(updatePayload)
|
||||
}
|
||||
|
||||
// Returns Boolean If update was made
|
||||
updateSettings(settings) {
|
||||
if (!this.settings) {
|
||||
this.settings = { ...settings }
|
||||
return true
|
||||
}
|
||||
var madeUpdates = false
|
||||
|
||||
for (const key in this.settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] !== settings[key]) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new settings update has keys not currently in user settings
|
||||
for (const key in settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] === undefined) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return madeUpdates
|
||||
}
|
||||
|
||||
resetAudiobookProgress(audiobookId) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return false
|
||||
}
|
||||
return this.updateAudiobookProgress(audiobookId, {
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
isRead: false,
|
||||
lastUpdate: Date.now(),
|
||||
startedAt: null,
|
||||
finishedAt: null
|
||||
})
|
||||
}
|
||||
|
||||
deleteAudiobookProgress(audiobookId) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return false
|
||||
}
|
||||
delete this.audiobooks[audiobookId]
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = User
|
||||