mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-06-03 13:44:36 -04:00
Change: config page to multiple pages, Add: user permissions for accessible libraries #120, Add: map genre metadata tag #114, Add: experimental audio player keyboard controls #121, Add: view user audiobook progress list
This commit is contained in:
parent
7d9ed75a28
commit
ff1eeda468
@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute right-32 top-0 bottom-0">
|
<div class="absolute right-32 top-0 bottom-0">
|
||||||
<controls-volume-control v-model="volume" @input="updateVolume" />
|
<controls-volume-control ref="volumeControl" v-model="volume" @input="updateVolume" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2">
|
<div class="flex my-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@ -496,14 +496,39 @@ export default {
|
|||||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||||
this.updatePlaybackRate(settings.playbackRate)
|
this.updatePlaybackRate(settings.playbackRate)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
volumeUp() {
|
||||||
|
if (this.volume >= 1) return
|
||||||
|
this.volume = Math.min(1, this.volume + 0.1)
|
||||||
|
this.updateVolume(this.volume)
|
||||||
|
},
|
||||||
|
volumeDown() {
|
||||||
|
if (this.volume <= 0) return
|
||||||
|
this.volume = Math.max(0, this.volume - 0.1)
|
||||||
|
this.updateVolume(this.volume)
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
if (this.$refs.volumeControl && this.$refs.volumeControl.toggleMute) {
|
||||||
|
this.$refs.volumeControl.toggleMute()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hotkey(keyCode) {
|
||||||
|
if (keyCode === this.$hotkeys.PLAY_PAUSE) this.playPauseClick()
|
||||||
|
else if (keyCode === this.$hotkeys.JUMP_FORWARD) this.forward10()
|
||||||
|
else if (keyCode === this.$hotkeys.JUMP_BACKWARD) this.backward10()
|
||||||
|
else if (keyCode === this.$hotkeys.VOLUME_UP) this.volumeUp()
|
||||||
|
else if (keyCode === this.$hotkeys.VOLUME_DOWN) this.volumeDown()
|
||||||
|
else if (keyCode === this.$hotkeys.MUTE) this.toggleMute()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||||
this.init()
|
this.init()
|
||||||
|
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||||
|
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -8,13 +8,6 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||||
|
|
||||||
<!-- <div class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
|
|
||||||
</div> -->
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search />
|
<controls-global-search />
|
||||||
@ -133,9 +126,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickLibrary() {
|
|
||||||
this.$store.commit('libraries/setShowModal', true)
|
|
||||||
},
|
|
||||||
async back() {
|
async back() {
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
var popped = await this.$store.dispatch('popRoute')
|
||||||
var backTo = popped || '/'
|
var backTo = popped || '/'
|
||||||
|
56
client/components/app/ConfigSideNav.vue
Normal file
56
client/components/app/ConfigSideNav.vue
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-44 fixed left-0 top-16 z-40 h-full bg-bg bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-4">
|
||||||
|
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Settings</p>
|
||||||
|
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Libraries</p>
|
||||||
|
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Users</p>
|
||||||
|
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Backups</p>
|
||||||
|
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
|
<p>Log</p>
|
||||||
|
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute bottom-20 left-0 flex flex-col justify-center">
|
||||||
|
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||||
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
routeName() {
|
||||||
|
return this.$route.name
|
||||||
|
},
|
||||||
|
versionData() {
|
||||||
|
return this.$store.state.versionData || {}
|
||||||
|
},
|
||||||
|
hasUpdate() {
|
||||||
|
return !!this.versionData.hasUpdate
|
||||||
|
},
|
||||||
|
latestVersion() {
|
||||||
|
return this.versionData.latestVersion
|
||||||
|
},
|
||||||
|
githubTagUrl() {
|
||||||
|
return this.versionData.githubTagUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -164,11 +164,14 @@ export default {
|
|||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
|
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
|
||||||
},
|
},
|
||||||
|
isStreaming() {
|
||||||
|
return this.$store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||||
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks
|
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||||
|
@ -114,6 +114,9 @@ export default {
|
|||||||
this.volume = this.lastValue || 0.5
|
this.volume = this.lastValue || 0.5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
toggleMute() {
|
||||||
|
this.clickVolumeIcon()
|
||||||
|
},
|
||||||
clickVolumeTrack(e) {
|
clickVolumeTrack(e) {
|
||||||
var vol = e.offsetX / this.trackWidth
|
var vol = e.offsetX / this.trackWidth
|
||||||
vol = Math.min(Math.max(vol, 0), 1)
|
vol = Math.min(Math.max(vol, 0), 1)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<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>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
@ -64,6 +64,19 @@
|
|||||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Access All Libraries</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!newUser.permissions.accessAllLibraries" class="my-4">
|
||||||
|
<ui-multi-select-dropdown v-model="newUser.librariesAccessible" :items="libraryItems" label="Libraries Accessible to User" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4">
|
||||||
@ -116,14 +129,31 @@ export default {
|
|||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account && this.account.type === 'root'
|
||||||
|
},
|
||||||
|
libraries() {
|
||||||
|
return this.$store.state.libraries.libraries
|
||||||
|
},
|
||||||
|
libraryItems() {
|
||||||
|
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
accessAllLibrariesToggled(val) {
|
||||||
|
if (!val && !this.newUser.librariesAccessible.length) {
|
||||||
|
this.newUser.librariesAccessible = this.libraries.map((l) => l.id)
|
||||||
|
} else if (val && this.newUser.librariesAccessible.length) {
|
||||||
|
this.newUser.librariesAccessible = []
|
||||||
|
}
|
||||||
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.newUser.username) {
|
if (!this.newUser.username) {
|
||||||
this.$toast.error('Enter a username')
|
this.$toast.error('Enter a username')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!this.newUser.permissions.accessAllLibraries && !this.newUser.librariesAccessible.length) {
|
||||||
|
this.$toast.error('Must select at least one library')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isNew) {
|
if (this.isNew) {
|
||||||
this.submitCreateAccount()
|
this.submitCreateAccount()
|
||||||
@ -139,6 +169,7 @@ export default {
|
|||||||
if (account.type === 'root' && !account.isActive) return
|
if (account.type === 'root' && !account.isActive) return
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
console.log('Calling update', account)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$patch(`/api/user/${this.account.id}`, account)
|
.$patch(`/api/user/${this.account.id}`, account)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@ -146,6 +177,7 @@ export default {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
this.$toast.error(`Failed to update account: ${data.error}`)
|
this.$toast.error(`Failed to update account: ${data.error}`)
|
||||||
} else {
|
} else {
|
||||||
|
console.log('Account updated', data.user)
|
||||||
this.$toast.success('Account updated')
|
this.$toast.success('Account updated')
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
@ -197,12 +229,14 @@ export default {
|
|||||||
init() {
|
init() {
|
||||||
this.isNew = !this.account
|
this.isNew = !this.account
|
||||||
if (this.account) {
|
if (this.account) {
|
||||||
|
var librariesAccessible = this.account.librariesAccessible || []
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
username: this.account.username,
|
username: this.account.username,
|
||||||
password: this.account.password,
|
password: this.account.password,
|
||||||
type: this.account.type,
|
type: this.account.type,
|
||||||
isActive: this.account.isActive,
|
isActive: this.account.isActive,
|
||||||
permissions: { ...this.account.permissions }
|
permissions: { ...this.account.permissions },
|
||||||
|
librariesAccessible: [...librariesAccessible]
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newUser = {
|
this.newUser = {
|
||||||
@ -214,8 +248,10 @@ export default {
|
|||||||
download: true,
|
download: true,
|
||||||
update: false,
|
update: false,
|
||||||
delete: false,
|
delete: false,
|
||||||
upload: false
|
upload: false,
|
||||||
}
|
accessAllLibraries: true
|
||||||
|
},
|
||||||
|
librariesAccessible: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="500" :height="'unset'">
|
<modals-modal v-model="show" name="chapters" :width="500" :height="'unset'">
|
||||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<template v-for="chap in chapters">
|
<template v-for="chap in chapters">
|
||||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
|
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<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>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="75">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
<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>
|
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||||
|
@ -1,99 +0,0 @@
|
|||||||
<template>
|
|
||||||
<modals-modal v-model="show" :width="700" :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>
|
|
||||||
<div v-if="show" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
|
|
||||||
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
|
|
||||||
<div class="flex items-center mb-4">
|
|
||||||
<p>{{ libraries.length }} Libraries</p>
|
|
||||||
<!-- <div class="flex-grow" />
|
|
||||||
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-for="library in libraries">
|
|
||||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
|
|
||||||
</div>
|
|
||||||
</modals-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selectedLibrary: null,
|
|
||||||
processing: false,
|
|
||||||
showAddLibrary: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
show: {
|
|
||||||
get() {
|
|
||||||
return this.$store.state.libraries.showModal
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this.$store.commit('libraries/setShowModal', val)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title() {
|
|
||||||
return 'Libraries'
|
|
||||||
},
|
|
||||||
currentLibrary() {
|
|
||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
|
||||||
return this.currentLibrary ? this.currentLibrary.id : null
|
|
||||||
},
|
|
||||||
libraries() {
|
|
||||||
return this.$store.state.libraries.libraries
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
show(newVal) {
|
|
||||||
if (newVal) this.showAddLibrary = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async clickLibrary(library) {
|
|
||||||
await this.$store.dispatch('libraries/fetch', library.id)
|
|
||||||
this.$router.push(`/library/${library.id}`)
|
|
||||||
this.show = false
|
|
||||||
},
|
|
||||||
editLibrary(library) {
|
|
||||||
this.selectedLibrary = library
|
|
||||||
this.showAddLibrary = true
|
|
||||||
},
|
|
||||||
addLibraryClick() {
|
|
||||||
this.selectedLibrary = null
|
|
||||||
this.showAddLibrary = true
|
|
||||||
},
|
|
||||||
deleteLibrary(library) {
|
|
||||||
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
|
|
||||||
console.log('Delete library', library)
|
|
||||||
this.processing = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/library/${library.id}`)
|
|
||||||
.then(() => {
|
|
||||||
console.log('Library delete success')
|
|
||||||
this.$toast.success(`Library "${library.name}" deleted`)
|
|
||||||
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete library', error)
|
|
||||||
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
|
||||||
this.$toast.error(errMsg)
|
|
||||||
this.processing = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {},
|
|
||||||
beforeDestroy() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -2,7 +2,7 @@
|
|||||||
<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 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-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">
|
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
@ -18,6 +18,7 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
name: String,
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
persistent: {
|
persistent: {
|
||||||
@ -73,6 +74,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clickClose() {
|
||||||
|
this.show = false
|
||||||
|
},
|
||||||
clickBg(vm, ev) {
|
clickBg(vm, ev) {
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (vm.srcElement.classList.contains('modal-bg')) {
|
if (vm.srcElement.classList.contains('modal-bg')) {
|
||||||
@ -85,11 +89,15 @@ export default {
|
|||||||
this.content.style.transform = 'scale(1)'
|
this.content.style.transform = 'scale(1)'
|
||||||
}, 10)
|
}, 10)
|
||||||
document.documentElement.classList.add('modal-open')
|
document.documentElement.classList.add('modal-open')
|
||||||
|
|
||||||
|
this.$store.commit('setOpenModal', this.name)
|
||||||
},
|
},
|
||||||
setHide() {
|
setHide() {
|
||||||
this.content.style.transform = 'scale(0)'
|
this.content.style.transform = 'scale(0)'
|
||||||
this.el.remove()
|
this.el.remove()
|
||||||
document.documentElement.classList.remove('modal-open')
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
|
||||||
|
this.$store.commit('setOpenModal', null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -17,17 +17,11 @@
|
|||||||
<th class="w-32">Created</th>
|
<th class="w-32">Created</th>
|
||||||
<th class="w-32"></th>
|
<th class="w-32"></th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
<tr v-for="user in users" :key="user.id" class="cursor-pointer" :class="user.isActive ? '' : 'bg-error bg-opacity-20'" @click="$router.push(`/config/users/${user.id}`)">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span v-if="usersOnline[user.id]" class="w-3 h-3 text-sm mr-2 text-success animate-pulse"
|
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
||||||
><svg viewBox="0 0 24 24">
|
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||||
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" /></svg
|
|
||||||
></span>
|
|
||||||
<svg v-else class="w-3 h-3 mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
|
||||||
</svg>
|
|
||||||
{{ user.username }} <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
@ -49,10 +43,15 @@
|
|||||||
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
{{ $formatDate(user.createdAt, 'MMM d, yyyy') }}
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="py-0">
|
||||||
<div class="w-full flex justify-center">
|
<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 class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click.stop="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 class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-opacity-100 cursor-pointer" @click.stop="editUser(user)">
|
||||||
|
<span class="material-icons text-base">edit</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="user.type !== 'root'" class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="deleteUserClick(user)">
|
||||||
|
<span class="material-icons text-base">delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -81,21 +80,8 @@ export default {
|
|||||||
return this.$store.state.streamAudiobook
|
return this.$store.state.streamAudiobook
|
||||||
},
|
},
|
||||||
usersOnline() {
|
usersOnline() {
|
||||||
var _users = this.$store.state.users.users
|
var usermap = {}
|
||||||
|
this.$store.state.users.users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
||||||
var currUserStream = null
|
|
||||||
if (this.userStream) {
|
|
||||||
currUserStream = {
|
|
||||||
audiobook: this.userStream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var usermap = {
|
|
||||||
[this.currentUserId]: {
|
|
||||||
online: true,
|
|
||||||
stream: currUserStream
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_users.forEach((u) => (usermap[u.id] = { online: true, stream: u.stream }))
|
|
||||||
return usermap
|
return usermap
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -104,7 +90,9 @@ export default {
|
|||||||
var abs = Object.values(audiobooks)
|
var abs = Object.values(audiobooks)
|
||||||
if (abs.length) {
|
if (abs.length) {
|
||||||
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
abs = abs.sort((a, b) => a.lastUpdate - b.lastUpdate)
|
||||||
return abs[0] && abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
// Book object is attached on request
|
||||||
|
if (abs[0].book) return abs[0].book.title
|
||||||
|
return abs[0].audiobookTitle ? abs[0].audiobookTitle : null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
@ -141,7 +129,9 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get('/api/users')
|
.$get('/api/users')
|
||||||
.then((users) => {
|
.then((users) => {
|
||||||
this.users = users
|
this.users = users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@ -192,16 +182,21 @@ export default {
|
|||||||
#accounts {
|
#accounts {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #474747;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#accounts td,
|
#accounts td,
|
||||||
#accounts th {
|
#accounts th {
|
||||||
border: 1px solid #2e2e2e;
|
/* border: 1px solid #2e2e2e; */
|
||||||
padding: 8px 8px;
|
padding: 8px 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#accounts td.py-0 {
|
||||||
|
padding: 0px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
#accounts tr:nth-child(even) {
|
#accounts tr:nth-child(even) {
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
|
<div v-if="currentLibrary" class="relative w-36 h-8" v-click-outside="clickOutside">
|
||||||
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-10 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative h-full w-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||||
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
<span class="block truncate text-sm">{{ currentLibrary.name }}</span>
|
||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<transition name="menu">
|
<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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
<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 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||||
<template v-for="library in libraries">
|
<template v-for="library in librariesFiltered">
|
||||||
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
<li :key="library.id" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="selectLibrary(library)">
|
||||||
<div class="flex items-center px-3">
|
<div class="flex items-center px-3">
|
||||||
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
<widgets-library-icon :icon="currentLibraryIcon" class="mr-2" />
|
||||||
@ -43,8 +43,17 @@ export default {
|
|||||||
libraries() {
|
libraries() {
|
||||||
return this.$store.getters['libraries/getSortedLibraries']()
|
return this.$store.getters['libraries/getSortedLibraries']()
|
||||||
},
|
},
|
||||||
libraryItems() {
|
canUserAccessAllLibraries() {
|
||||||
return this.libraries.map((lib) => ({ value: lib.id, text: lib.name }))
|
return this.$store.getters['user/getUserCanAccessAllLibraries']
|
||||||
|
},
|
||||||
|
userLibrariesAccessible() {
|
||||||
|
return this.$store.getters['user/getLibrariesAccessible']
|
||||||
|
},
|
||||||
|
librariesFiltered() {
|
||||||
|
if (this.canUserAccessAllLibraries) return this.libraries
|
||||||
|
return this.libraries.filter((lib) => {
|
||||||
|
return this.userLibrariesAccessible.includes(lib.id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
120
client/components/ui/MultiSelectDropdown.vue
Normal file
120
client/components/ui/MultiSelectDropdown.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full" v-click-outside="closeMenu">
|
||||||
|
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||||
|
<div ref="wrapper" class="relative">
|
||||||
|
<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-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||||
|
<div v-for="item in selectedItems" :key="item.value" 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.value)">close</span>
|
||||||
|
</div>
|
||||||
|
{{ item.text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul ref="menu" v-show="showMenu" class="absolute z-50 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" 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.text }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="selected.includes(item.value)" 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="!items.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: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
label: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showMenu: false,
|
||||||
|
menu: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selected: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedItems() {
|
||||||
|
return (this.value || []).map((v) => {
|
||||||
|
return this.items.find((i) => i.value === v) || {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
recalcMenuPos() {
|
||||||
|
if (!this.menu) return
|
||||||
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
|
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||||
|
this.menu.style.left = boundingBox.x + 'px'
|
||||||
|
this.menu.style.width = boundingBox.width + 'px'
|
||||||
|
},
|
||||||
|
unmountMountMenu() {
|
||||||
|
if (!this.$refs.menu) return
|
||||||
|
this.menu = this.$refs.menu
|
||||||
|
|
||||||
|
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||||
|
this.menu.remove()
|
||||||
|
document.body.appendChild(this.menu)
|
||||||
|
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||||
|
this.menu.style.left = boundingBox.x + 'px'
|
||||||
|
this.menu.style.width = boundingBox.width + 'px'
|
||||||
|
},
|
||||||
|
clickedOption(e, item) {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
var newSelected = null
|
||||||
|
if (this.selected.includes(item.value)) {
|
||||||
|
newSelected = this.selected.filter((s) => s !== item.value)
|
||||||
|
} else {
|
||||||
|
newSelected = this.selected.concat([item.value])
|
||||||
|
}
|
||||||
|
this.$emit('input', newSelected)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeMenu() {
|
||||||
|
this.showMenu = false
|
||||||
|
},
|
||||||
|
clickWrapper() {
|
||||||
|
this.showMenu = !this.showMenu
|
||||||
|
},
|
||||||
|
removeItem(itemValue) {
|
||||||
|
var remaining = this.selected.filter((i) => i !== itemValue)
|
||||||
|
this.$emit('input', remaining)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.recalcMenuPos()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
26
client/components/widgets/OnlineIndicator.vue
Normal file
26
client/components/widgets/OnlineIndicator.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-3 h-3">
|
||||||
|
<div v-if="value" class="w-full h-full text-sm mr-2 text-success animate-pulse">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<svg v-else class="w-full h-full mr-2 text-white text-opacity-20" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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 {
|
|
||||||
hasCanceled: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
isScanning(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.hasCanceled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
text() {
|
|
||||||
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[this.scanProgressKey]
|
|
||||||
},
|
|
||||||
scanPercent() {
|
|
||||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
|
||||||
},
|
|
||||||
scanNum() {
|
|
||||||
return this.scanProgress ? this.scanProgress.done : 0
|
|
||||||
},
|
|
||||||
scanTotal() {
|
|
||||||
return this.scanProgress ? this.scanProgress.total : 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
cancelScan() {
|
|
||||||
this.hasCanceled = true
|
|
||||||
this.$root.socket.emit('cancel_scan')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
@ -5,10 +5,9 @@
|
|||||||
<Nuxt />
|
<Nuxt />
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-stream-container ref="streamContainer" />
|
||||||
<modals-libraries-modal />
|
|
||||||
<modals-edit-modal />
|
<modals-edit-modal />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
<!-- <widgets-scan-alert /> -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -76,6 +75,12 @@ export default {
|
|||||||
if (payload.backups && payload.backups.length) {
|
if (payload.backups && payload.backups.length) {
|
||||||
this.$store.commit('setBackups', payload.backups)
|
this.$store.commit('setBackups', payload.backups)
|
||||||
}
|
}
|
||||||
|
if (payload.usersOnline) {
|
||||||
|
this.$store.commit('users/resetUsers')
|
||||||
|
payload.usersOnline.forEach((user) => {
|
||||||
|
this.$store.commit('users/updateUser', user)
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||||
@ -320,9 +325,38 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
checkActiveElementIsInput() {
|
||||||
|
var activeElement = document.activeElement
|
||||||
|
var inputs = ['input', 'select', 'button', 'textarea']
|
||||||
|
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
|
||||||
|
},
|
||||||
|
keyUp(e) {
|
||||||
|
if (!this.$store.state.showExperimentalFeatures) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var keyCode = e.keyCode || e.which
|
||||||
|
|
||||||
|
// If an input is focused then ignore key press
|
||||||
|
if (this.checkActiveElementIsInput()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal is open ignore key press
|
||||||
|
if (this.$store.state.openModal) {
|
||||||
|
// console.log('Modal is open', this.$store.state.openModal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing audiobook
|
||||||
|
if (this.$store.state.streamAudiobook) {
|
||||||
|
this.$eventBus.$emit('player-hotkey', keyCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
document.addEventListener('keyup', this.keyUp)
|
||||||
|
|
||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
this.$store.dispatch('libraries/load')
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
@ -343,6 +377,9 @@ export default {
|
|||||||
this.$toast.error(this.$route.query.error)
|
this.$toast.error(this.$route.query.error)
|
||||||
this.$router.replace(this.$route.path)
|
this.$router.replace(this.$route.path)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
document.removeEventListener('keyup', this.keyUp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -12,7 +12,7 @@ export default function (context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'config-log' && from.name !== 'upload' && from.name !== 'account') {
|
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
||||||
var _history = [...store.state.routeHistory]
|
var _history = [...store.state.routeHistory]
|
||||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||||
_history.push(from.fullPath)
|
_history.push(from.fullPath)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -61,7 +61,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
logout() {
|
logout() {
|
||||||
this.$axios.$post('/logout').catch((error) => {
|
var rootSocket = this.$root.socket || {}
|
||||||
|
const logoutPayload = {
|
||||||
|
socketId: rootSocket.id
|
||||||
|
}
|
||||||
|
this.$axios.$post('/logout', logoutPayload).catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
if (localStorage.getItem('token')) {
|
if (localStorage.getItem('token')) {
|
||||||
|
45
client/pages/config.vue
Normal file
45
client/pages/config.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||||
|
<app-config-side-nav />
|
||||||
|
<div class="w-full max-w-4xl mx-auto">
|
||||||
|
<nuxt-child />
|
||||||
|
</div>
|
||||||
|
<div class="fixed bottom-0 right-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsRoot']) {
|
||||||
|
redirect('/?error=unauthorized')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamAudiobook() {
|
||||||
|
return this.$store.state.streamAudiobook
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setDeveloperMode() {
|
||||||
|
var value = !this.$store.state.developerMode
|
||||||
|
this.$store.commit('setDeveloperMode', value)
|
||||||
|
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||||
|
}
|
||||||
|
// saveMetadataComplete(result) {
|
||||||
|
// this.savingMetadata = false
|
||||||
|
// if (!result) return
|
||||||
|
// this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||||
|
// },
|
||||||
|
// saveMetadataFiles() {
|
||||||
|
// this.savingMetadata = true
|
||||||
|
// this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||||
|
// this.$root.socket.emit('save_metadata')
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
70
client/pages/config/backups.vue
Normal file
70
client/pages/config/backups.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<h1 class="text-xl">Backups</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||||
|
<ui-tooltip :text="dailyBackupsTooltip">
|
||||||
|
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||||
|
|
||||||
|
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<tables-backups-table />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
updatingServerSettings: false,
|
||||||
|
dailyBackups: true,
|
||||||
|
backupsToKeep: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
dailyBackupsTooltip() {
|
||||||
|
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateBackupsSettings() {
|
||||||
|
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
||||||
|
this.$toast.error('Invalid number of backups to keep')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updatePayload = {
|
||||||
|
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||||
|
backupsToKeep: Number(this.backupsToKeep)
|
||||||
|
}
|
||||||
|
this.updateServerSettings(updatePayload)
|
||||||
|
},
|
||||||
|
updateServerSettings(payload) {
|
||||||
|
this.updatingServerSettings = true
|
||||||
|
this.$store
|
||||||
|
.dispatch('updateServerSettings', payload)
|
||||||
|
.then((success) => {
|
||||||
|
console.log('Updated Server Settings', success)
|
||||||
|
this.updatingServerSettings = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to update server settings', error)
|
||||||
|
this.updatingServerSettings = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,125 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<div>
|
||||||
<div class="w-full max-w-4xl mx-auto">
|
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||||
<tables-users-table />
|
|
||||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
|
||||||
|
|
||||||
<tables-libraries-table />
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
<div class="flex items-center mb-2">
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
<h1 class="text-xl">Settings</h1>
|
||||||
<div class="flex items-center mb-2">
|
|
||||||
<h1 class="text-xl">Settings</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
|
||||||
<ui-tooltip :text="parseSubtitleTooltip">
|
|
||||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
|
||||||
<ui-tooltip :text="scannerFindCoversTooltip">
|
|
||||||
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
|
||||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
|
||||||
<ui-tooltip :text="coverDestinationTooltip">
|
|
||||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
<ui-tooltip :text="parseSubtitleTooltip">
|
||||||
<div class="flex items-center mb-2">
|
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
<h1 class="text-xl">Backups</h1>
|
</ui-tooltip>
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
|
||||||
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
|
||||||
<ui-tooltip :text="dailyBackupsTooltip">
|
|
||||||
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center py-2">
|
|
||||||
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
|
||||||
|
|
||||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<tables-backups-table />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
||||||
<div class="flex items-center py-4">
|
<ui-tooltip :text="scannerFindCoversTooltip">
|
||||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
<div class="flex-grow" />
|
</ui-tooltip>
|
||||||
<ui-btn to="/config/log">View Logger</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
||||||
<div class="flex items-center py-4">
|
<ui-tooltip :text="coverDestinationTooltip">
|
||||||
<p class="font-mono">v{{ $config.version }}</p>
|
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
<div class="flex-grow" />
|
</ui-tooltip>
|
||||||
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
|
|
||||||
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
|
||||||
|
|
||||||
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
|
||||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
|
||||||
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="flex-grow" /> -->
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
<div class="h-0.5 bg-primary bg-opacity-30 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 class="flex-grow" />
|
||||||
|
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf" target="_blank">github</a>.</p>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
|
|
||||||
|
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
|
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||||
|
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ store, redirect }) {
|
|
||||||
if (!store.getters['user/getIsRoot']) {
|
|
||||||
redirect('/?error=unauthorized')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
storeCoversInAudiobookDir: false,
|
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
newServerSettings: {},
|
storeCoversInAudiobookDir: false,
|
||||||
updatingServerSettings: false,
|
updatingServerSettings: false,
|
||||||
dailyBackups: true,
|
newServerSettings: {}
|
||||||
backupsToKeep: 2
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -131,6 +83,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
saveMetadataTooltip() {
|
||||||
|
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||||
|
},
|
||||||
|
experimentalFeaturesTooltip() {
|
||||||
|
return 'Features in development that could use your feedback and help testing.'
|
||||||
|
},
|
||||||
|
serverSettings() {
|
||||||
|
return this.$store.state.serverSettings
|
||||||
|
},
|
||||||
parseSubtitleTooltip() {
|
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"'
|
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"'
|
||||||
},
|
},
|
||||||
@ -140,30 +101,6 @@ export default {
|
|||||||
scannerFindCoversTooltip() {
|
scannerFindCoversTooltip() {
|
||||||
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
|
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
|
||||||
},
|
},
|
||||||
saveMetadataTooltip() {
|
|
||||||
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
|
||||||
},
|
|
||||||
experimentalFeaturesTooltip() {
|
|
||||||
return 'Features in development that could use your feedback and help testing.'
|
|
||||||
},
|
|
||||||
dailyBackupsTooltip() {
|
|
||||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
|
||||||
},
|
|
||||||
backupsToKeepTooltip() {
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
serverSettings() {
|
|
||||||
return this.$store.state.serverSettings
|
|
||||||
},
|
|
||||||
streamAudiobook() {
|
|
||||||
return this.$store.state.streamAudiobook
|
|
||||||
},
|
|
||||||
isScanning() {
|
|
||||||
return this.$store.state.isScanning
|
|
||||||
},
|
|
||||||
isScanningCovers() {
|
|
||||||
return this.$store.state.isScanningCovers
|
|
||||||
},
|
|
||||||
showExperimentalFeatures: {
|
showExperimentalFeatures: {
|
||||||
get() {
|
get() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
return this.$store.state.showExperimentalFeatures
|
||||||
@ -174,17 +111,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateBackupsSettings() {
|
|
||||||
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
|
||||||
this.$toast.error('Invalid number of backups to keep')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var updatePayload = {
|
|
||||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
|
||||||
backupsToKeep: Number(this.backupsToKeep)
|
|
||||||
}
|
|
||||||
this.updateServerSettings(updatePayload)
|
|
||||||
},
|
|
||||||
updateScannerFindCovers(val) {
|
updateScannerFindCovers(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
scannerFindCovers: !!val
|
scannerFindCovers: !!val
|
||||||
@ -214,23 +140,13 @@ export default {
|
|||||||
this.updatingServerSettings = false
|
this.updatingServerSettings = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
setDeveloperMode() {
|
initServerSettings() {
|
||||||
var value = !this.$store.state.developerMode
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.$store.commit('setDeveloperMode', value)
|
|
||||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
},
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
scan() {
|
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||||
},
|
|
||||||
saveMetadataComplete(result) {
|
|
||||||
this.savingMetadata = false
|
|
||||||
if (!result) return
|
|
||||||
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
|
||||||
},
|
|
||||||
saveMetadataFiles() {
|
|
||||||
this.savingMetadata = true
|
|
||||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
|
||||||
this.$root.socket.emit('save_metadata')
|
|
||||||
},
|
},
|
||||||
resetAudiobooks() {
|
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?')) {
|
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?')) {
|
||||||
@ -248,20 +164,10 @@ export default {
|
|||||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
init() {
|
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
|
||||||
this.initServerSettings()
|
|
||||||
},
|
|
||||||
initServerSettings() {
|
|
||||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
|
||||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
|
||||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
|
||||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.initServerSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
16
client/pages/config/libraries.vue
Normal file
16
client/pages/config/libraries.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tables-libraries-table />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
110
client/pages/config/users/_id.vue
Normal file
110
client/pages/config/users/_id.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-full">
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
|
<nuxt-link to="/config/users" class="text-white text-opacity-70 hover:text-opacity-100 hover:bg-white hover:bg-opacity-5 cursor-pointer rounded-full">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="h-10 w-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</div>
|
||||||
|
<p class="pl-1">All Users</p>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<div class="flex items-center mb-2 mt-4">
|
||||||
|
<widgets-online-indicator :value="!!userOnline" />
|
||||||
|
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||||
|
<div class="py-2">
|
||||||
|
<h1 class="text-lg mb-2 text-white text-opacity-90">Reading Progress</h1>
|
||||||
|
<table v-if="userAudiobooks.length" class="userAudiobooksTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-16 text-left">Book</th>
|
||||||
|
<th class="text-left"></th>
|
||||||
|
<th class="w-32">Progress</th>
|
||||||
|
<th class="w-40">Started At</th>
|
||||||
|
<th class="w-40">Last Update</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="ab in userAudiobooks" :key="ab.audiobookId" :class="!ab.isRead ? '' : 'isRead'">
|
||||||
|
<td>
|
||||||
|
<cards-book-cover :width="50" :audiobook="ab" />
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
<p>{{ ab.book ? ab.book.title : ab.audiobookTitle || 'Unknown' }}</p>
|
||||||
|
<p v-if="ab.book && ab.book.author" class="text-white text-opacity-50 text-sm font-sans">by {{ ab.book.author }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ Math.floor(ab.progress * 100) }}%</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<ui-tooltip v-if="ab.startedAt" direction="top" :text="$formatDate(ab.startedAt, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-sm">{{ $dateDistanceFromNow(ab.startedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<ui-tooltip v-if="ab.lastUpdate" direction="top" :text="$formatDate(ab.lastUpdate, 'MMMM do, yyyy HH:mm')">
|
||||||
|
<p class="text-sm">{{ $dateDistanceFromNow(ab.lastUpdate) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p v-else class="text-white text-opacity-50">Nothing read yet...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ params, redirect, app }) {
|
||||||
|
var user = await app.$axios.$get(`/api/user/${params.id}`).catch((error) => {
|
||||||
|
console.error('Failed to get user', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
if (!user) return redirect('/config/users')
|
||||||
|
return {
|
||||||
|
user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
username() {
|
||||||
|
return this.user.username
|
||||||
|
},
|
||||||
|
userOnline() {
|
||||||
|
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||||
|
},
|
||||||
|
userAudiobooks() {
|
||||||
|
return Object.values(this.user.audiobooks || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.userAudiobooksTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:nth-child(even) {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:not(:first-child) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr:hover:not(:first-child) {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable tr.isRead {
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
}
|
||||||
|
.userAudiobooksTable td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.userAudiobooksTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
16
client/pages/config/users/index.vue
Normal file
16
client/pages/config/users/index.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<tables-users-table />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -48,6 +48,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
setUser(user) {
|
||||||
|
// If user is not able to access main library, then set current library
|
||||||
|
// var userLibrariesAccessible = this.$store.getters['user/getLibrariesAccessible']
|
||||||
|
var userCanAccessAll = user.permissions ? !!user.permissions.accessAllLibraries : false
|
||||||
|
if (!userCanAccessAll) {
|
||||||
|
var accessibleLibraries = user.librariesAccessible || []
|
||||||
|
console.log('Setting user without all library access', accessibleLibraries)
|
||||||
|
if (accessibleLibraries.length && !accessibleLibraries.includes('main')) {
|
||||||
|
console.log('Setting current library', accessibleLibraries[0])
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', accessibleLibraries[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if (userLibrariesAccessible.length && !userLibrariesAccessible.includes('main')) {
|
||||||
|
// this.$store.commit('libraries/setCurrentLibrary', userLibrariesAccessible[0])
|
||||||
|
// }
|
||||||
|
|
||||||
|
this.$store.commit('user/setUser', user)
|
||||||
|
},
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
@ -65,7 +83,7 @@ export default {
|
|||||||
if (authRes && authRes.error) {
|
if (authRes && authRes.error) {
|
||||||
this.error = authRes.error
|
this.error = authRes.error
|
||||||
} else if (authRes) {
|
} else if (authRes) {
|
||||||
this.$store.commit('user/setUser', authRes.user)
|
this.setUser(authRes.user)
|
||||||
}
|
}
|
||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
@ -83,7 +101,7 @@ export default {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.$store.commit('user/setUser', res.user)
|
this.setUser(res.user)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -15,6 +15,17 @@ const Constants = {
|
|||||||
CoverDestination
|
CoverDestination
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Hotkeys = {
|
||||||
|
PLAY_PAUSE: 32, // Space
|
||||||
|
JUMP_FORWARD: 39, // ArrowRight
|
||||||
|
JUMP_BACKWARD: 37, // ArrowLeft
|
||||||
|
CLOSE: 27, // ESCAPE
|
||||||
|
VOLUME_UP: 38, // ArrowUp
|
||||||
|
VOLUME_DOWN: 40, // ArrowDown
|
||||||
|
MUTE: 77, // M
|
||||||
|
}
|
||||||
|
|
||||||
export default ({ app }, inject) => {
|
export default ({ app }, inject) => {
|
||||||
inject('constants', Constants)
|
inject('constants', Constants)
|
||||||
|
inject('hotkeys', Hotkeys)
|
||||||
}
|
}
|
@ -1,6 +1,8 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import { formatDistance, format } from 'date-fns'
|
import { formatDistance, format } from 'date-fns'
|
||||||
|
|
||||||
|
Vue.prototype.$eventBus = new Vue()
|
||||||
|
|
||||||
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
Vue.prototype.$dateDistanceFromNow = (unixms) => {
|
||||||
|
@ -18,14 +18,18 @@ export const state = () => ({
|
|||||||
routeHistory: [],
|
routeHistory: [],
|
||||||
showExperimentalFeatures: false,
|
showExperimentalFeatures: false,
|
||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: []
|
bookshelfBookIds: [],
|
||||||
|
openModal: null
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
getIsAudiobookSelected: state => audiobookId => {
|
getIsAudiobookSelected: state => audiobookId => {
|
||||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
return !!state.selectedAudiobooks.includes(audiobookId)
|
||||||
},
|
},
|
||||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
|
getNumAudiobooksSelected: state => state.selectedAudiobooks.length,
|
||||||
|
getAudiobookIdStreaming: state => {
|
||||||
|
return state.streamAudiobook ? state.streamAudiobook.id : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -155,5 +159,8 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
setBackups(state, val) {
|
setBackups(state, val) {
|
||||||
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
|
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
},
|
||||||
|
setOpenModal(state, val) {
|
||||||
|
state.openModal = val
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,7 +3,6 @@ export const state = () => ({
|
|||||||
lastLoad: 0,
|
lastLoad: 0,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
currentLibraryId: 'main',
|
currentLibraryId: 'main',
|
||||||
showModal: false,
|
|
||||||
folders: [],
|
folders: [],
|
||||||
folderLastUpdate: 0
|
folderLastUpdate: 0
|
||||||
})
|
})
|
||||||
@ -42,12 +41,18 @@ export const actions = {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetch({ state, commit, rootState }, libraryId) {
|
fetch({ state, commit, rootState, rootGetters }, libraryId) {
|
||||||
if (!rootState.user || !rootState.user.user) {
|
if (!rootState.user || !rootState.user.user) {
|
||||||
console.error('libraries/fetch - User not set')
|
console.error('libraries/fetch - User not set')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var canUserAccessLibrary = rootGetters['user/getCanAccessLibrary'](libraryId)
|
||||||
|
if (!canUserAccessLibrary) {
|
||||||
|
console.warn('Access not allowed to library')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var library = state.libraries.find(lib => lib.id === libraryId)
|
var library = state.libraries.find(lib => lib.id === libraryId)
|
||||||
if (library) {
|
if (library) {
|
||||||
commit('setCurrentLibrary', libraryId)
|
commit('setCurrentLibrary', libraryId)
|
||||||
@ -102,9 +107,6 @@ export const mutations = {
|
|||||||
setFoldersLastUpdate(state) {
|
setFoldersLastUpdate(state) {
|
||||||
state.folderLastUpdate = Date.now()
|
state.folderLastUpdate = Date.now()
|
||||||
},
|
},
|
||||||
setShowModal(state, val) {
|
|
||||||
state.showModal = val
|
|
||||||
},
|
|
||||||
setLastLoad(state) {
|
setLastLoad(state) {
|
||||||
state.lastLoad = Date.now()
|
state.lastLoad = Date.now()
|
||||||
},
|
},
|
||||||
|
@ -33,6 +33,19 @@ export const getters = {
|
|||||||
},
|
},
|
||||||
getUserCanUpload: (state) => {
|
getUserCanUpload: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
||||||
|
},
|
||||||
|
getUserCanAccessAllLibraries: (state) => {
|
||||||
|
return state.user && state.user.permissions ? !!state.user.permissions.accessAllLibraries : false
|
||||||
|
},
|
||||||
|
getLibrariesAccessible: (state, getters) => {
|
||||||
|
if (!state.user) return []
|
||||||
|
if (getters.getUserCanAccessAllLibraries) return []
|
||||||
|
return state.user.librariesAccessible || []
|
||||||
|
},
|
||||||
|
getCanAccessLibrary: (state, getters) => (libraryId) => {
|
||||||
|
if (!state.user) return false
|
||||||
|
if (getters.getUserCanAccessAllLibraries) return true
|
||||||
|
return getters.getLibrariesAccessible.includes(libraryId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +73,7 @@ export const actions = {
|
|||||||
export const mutations = {
|
export const mutations = {
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.user = user
|
state.user = user
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.token) localStorage.setItem('token', user.token)
|
if (user.token) localStorage.setItem('token', user.token)
|
||||||
} else {
|
} else {
|
||||||
|
@ -4,7 +4,9 @@ export const state = () => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
|
getIsUserOnline: state => id => {
|
||||||
|
return state.users.find(u => u.id === id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -12,6 +14,9 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
resetUsers(state) {
|
||||||
|
state.users = []
|
||||||
|
},
|
||||||
updateUser(state, user) {
|
updateUser(state, user) {
|
||||||
var index = state.users.findIndex(u => u.id === user.id)
|
var index = state.users.findIndex(u => u.id === user.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
8
package-lock.json
generated
8
package-lock.json
generated
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.4.3",
|
"version": "1.4.13",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1222,9 +1222,9 @@
|
|||||||
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
|
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
|
||||||
},
|
},
|
||||||
"njodb": {
|
"njodb": {
|
||||||
"version": "0.4.20",
|
"version": "0.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/njodb/-/njodb-0.4.20.tgz",
|
"resolved": "https://registry.npmjs.org/njodb/-/njodb-0.4.21.tgz",
|
||||||
"integrity": "sha512-y/V9yTSa6fXlfkD453o8engmbFvMabpogSYt53sNft48oqzO5tk4OTl564Zf2IN8JtJDp4ShnZE4hIXePqfvhg==",
|
"integrity": "sha512-3qLMzwIZUgT1yq2PCzJlT6FFK/zfLHz71QnFeE9ec4KKJH9abY4SXnmHVaWP7wVq+lY77wW1F+EeKG9gm8j6WA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"proper-lockfile": "^4.1.2"
|
"proper-lockfile": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.4.13",
|
"version": "1.4.14",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"ip": "^1.1.5",
|
"ip": "^1.1.5",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"libgen": "^2.1.0",
|
"libgen": "^2.1.0",
|
||||||
"njodb": "^0.4.20",
|
"njodb": "^0.4.21",
|
||||||
"node-cron": "^3.0.0",
|
"node-cron": "^3.0.0",
|
||||||
"node-dir": "^0.1.17",
|
"node-dir": "^0.1.17",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
|
@ -63,6 +63,7 @@ class ApiController {
|
|||||||
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
||||||
this.router.get('/users', this.getUsers.bind(this))
|
this.router.get('/users', this.getUsers.bind(this))
|
||||||
this.router.post('/user', this.createUser.bind(this))
|
this.router.post('/user', this.createUser.bind(this))
|
||||||
|
this.router.get('/user/:id', this.getUser.bind(this))
|
||||||
this.router.patch('/user/:id', this.updateUser.bind(this))
|
this.router.patch('/user/:id', this.updateUser.bind(this))
|
||||||
this.router.delete('/user/:id', this.deleteUser.bind(this))
|
this.router.delete('/user/:id', this.deleteUser.bind(this))
|
||||||
|
|
||||||
@ -314,8 +315,17 @@ class ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAudiobook(req, res) {
|
getAudiobook(req, res) {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||||
if (!audiobook) return res.sendStatus(404)
|
if (!audiobook) return res.sendStatus(404)
|
||||||
|
|
||||||
|
// Check user can access this audiobooks library
|
||||||
|
if (!req.user.checkCanAccessLibrary(audiobook.libraryId)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
res.json(audiobook.toJSONExpanded())
|
res.json(audiobook.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -522,20 +532,23 @@ class ApiController {
|
|||||||
res.sendStatus(200)
|
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) {
|
async resetUserAudiobookProgress(req, res) {
|
||||||
req.user.resetAudiobookProgress(req.params.id)
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
|
||||||
|
if (!audiobook) {
|
||||||
|
return res.status(404).send('Audiobook not found')
|
||||||
|
}
|
||||||
|
req.user.resetAudiobookProgress(audiobook)
|
||||||
await this.db.updateEntity('user', req.user)
|
await this.db.updateEntity('user', req.user)
|
||||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUserAudiobookProgress(req, res) {
|
async updateUserAudiobookProgress(req, res) {
|
||||||
var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body)
|
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
|
||||||
|
if (!audiobook) {
|
||||||
|
return res.status(404).send('Audiobook not found')
|
||||||
|
}
|
||||||
|
var wasUpdated = req.user.updateAudiobookProgress(audiobook, req.body)
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
await this.db.updateEntity('user', req.user)
|
await this.db.updateEntity('user', req.user)
|
||||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||||
@ -551,8 +564,11 @@ class ApiController {
|
|||||||
|
|
||||||
var shouldUpdate = false
|
var shouldUpdate = false
|
||||||
abProgresses.forEach((progress) => {
|
abProgresses.forEach((progress) => {
|
||||||
var wasUpdated = req.user.updateAudiobookProgress(progress.audiobookId, progress)
|
var audiobook = this.db.audiobooks.find(ab => ab.id === progress.audiobookId)
|
||||||
if (wasUpdated) shouldUpdate = true
|
if (audiobook) {
|
||||||
|
var wasUpdated = req.user.updateAudiobookProgress(audiobook, progress)
|
||||||
|
if (wasUpdated) shouldUpdate = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
@ -591,6 +607,30 @@ class ApiController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userJsonWithBookProgressDetails(user) {
|
||||||
|
var json = user.toJSONForBrowser()
|
||||||
|
|
||||||
|
// User audiobook progress attach book details
|
||||||
|
if (json.audiobooks && Object.keys(json.audiobooks).length) {
|
||||||
|
for (const audiobookId in json.audiobooks) {
|
||||||
|
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||||
|
if (!audiobook) {
|
||||||
|
Logger.error('[ApiController] Audiobook not found for users progress ' + audiobookId)
|
||||||
|
} else {
|
||||||
|
json.audiobooks[audiobookId].book = audiobook.book.toJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsers(req, res) {
|
||||||
|
if (req.user.type !== 'root') return res.sendStatus(403)
|
||||||
|
var users = this.db.users.map(u => this.userJsonWithBookProgressDetails(u))
|
||||||
|
res.json(users)
|
||||||
|
}
|
||||||
|
|
||||||
async createUser(req, res) {
|
async createUser(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isRoot) {
|
||||||
Logger.warn('Non-root user attempted to create user', req.user)
|
Logger.warn('Non-root user attempted to create user', req.user)
|
||||||
@ -621,6 +661,20 @@ class ApiController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUser(req, res) {
|
||||||
|
if (!req.user.isRoot) {
|
||||||
|
Logger.error('User other than root attempting to get 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(this.userJsonWithBookProgressDetails(user))
|
||||||
|
}
|
||||||
|
|
||||||
async updateUser(req, res) {
|
async updateUser(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isRoot) {
|
||||||
Logger.error('User other than root attempting to update user', req.user)
|
Logger.error('User other than root attempting to update user', req.user)
|
||||||
|
@ -46,7 +46,7 @@ class Server {
|
|||||||
this.watcher = new Watcher(this.AudiobookPath)
|
this.watcher = new Watcher(this.AudiobookPath)
|
||||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
this.streamManager = new StreamManager(this.db, this.MetadataPath, this.emitter.bind(this))
|
||||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
@ -57,8 +57,6 @@ class Server {
|
|||||||
this.io = null
|
this.io = null
|
||||||
|
|
||||||
this.clients = {}
|
this.clients = {}
|
||||||
|
|
||||||
this.isScanningCovers = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get audiobooks() {
|
get audiobooks() {
|
||||||
@ -70,6 +68,11 @@ class Server {
|
|||||||
get serverSettings() {
|
get serverSettings() {
|
||||||
return this.db.serverSettings
|
return this.db.serverSettings
|
||||||
}
|
}
|
||||||
|
get usersOnline() {
|
||||||
|
return Object.values(this.clients).filter(c => c.user).map(client => {
|
||||||
|
return client.user.toJSONForPublic(this.streamManager.streams)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getClientsForUser(userId) {
|
getClientsForUser(userId) {
|
||||||
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
|
||||||
@ -83,6 +86,7 @@ class Server {
|
|||||||
clientEmitter(userId, ev, data) {
|
clientEmitter(userId, ev, data) {
|
||||||
var clients = this.getClientsForUser(userId)
|
var clients = this.getClientsForUser(userId)
|
||||||
if (!clients.length) {
|
if (!clients.length) {
|
||||||
|
console.log('clients', clients)
|
||||||
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
|
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
|
||||||
}
|
}
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
@ -193,7 +197,7 @@ class Server {
|
|||||||
var loginRateLimiter = this.getLoginRateLimiter()
|
var loginRateLimiter = this.getLoginRateLimiter()
|
||||||
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
|
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
|
||||||
|
|
||||||
app.post('/logout', this.logout.bind(this))
|
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||||
|
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
Logger.info('Recieved ping')
|
Logger.info('Recieved ping')
|
||||||
@ -203,10 +207,6 @@ class Server {
|
|||||||
// Used in development to set-up streams without authentication
|
// Used in development to set-up streams without authentication
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
app.use('/test-hls', this.hlsController.router)
|
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) => {
|
app.get('/catalog.json', (req, res) => {
|
||||||
Logger.error('Catalog request made', req.headers)
|
Logger.error('Catalog request made', req.headers)
|
||||||
res.json()
|
res.json()
|
||||||
@ -269,15 +269,16 @@ class Server {
|
|||||||
|
|
||||||
var _client = this.clients[socket.id]
|
var _client = this.clients[socket.id]
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
|
Logger.warn('[Server] Socket disconnect, no client ' + socket.id)
|
||||||
} else if (!_client.user) {
|
} else if (!_client.user) {
|
||||||
Logger.info('[SOCKET] Unauth socket disconnected ' + socket.id)
|
Logger.info('[Server] Unauth socket disconnected ' + socket.id)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
} else {
|
} else {
|
||||||
socket.broadcast.emit('user_offline', _client.user.toJSONForPublic(this.streamManager.streams))
|
Logger.debug('[Server] User Offline ' + _client.user.username)
|
||||||
|
this.io.emit('user_offline', _client.user.toJSONForPublic(this.streamManager.streams))
|
||||||
|
|
||||||
const disconnectTime = Date.now() - _client.connected_at
|
const disconnectTime = Date.now() - _client.connected_at
|
||||||
Logger.info(`[SOCKET] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
|
||||||
delete this.clients[socket.id]
|
delete this.clients[socket.id]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -426,6 +427,27 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logout(req, res) {
|
logout(req, res) {
|
||||||
|
var { socketId } = req.body
|
||||||
|
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${socketId}`)
|
||||||
|
|
||||||
|
// Strip user and client from client and client socket
|
||||||
|
if (socketId && this.clients[socketId]) {
|
||||||
|
var client = this.clients[socketId]
|
||||||
|
var clientSocket = client.socket
|
||||||
|
Logger.debug(`[Server] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
|
||||||
|
|
||||||
|
if (client.user) {
|
||||||
|
Logger.debug('[Server] User Offline ' + client.user.username)
|
||||||
|
this.io.emit('user_offline', client.user.toJSONForPublic(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.clients[socketId].user
|
||||||
|
delete this.clients[socketId].stream
|
||||||
|
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
|
||||||
|
} else if (socketId) {
|
||||||
|
Logger.warn(`[Server] No client for socket ${socketId}`)
|
||||||
|
}
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -444,6 +466,11 @@ class Server {
|
|||||||
return socket.emit('invalid_token')
|
return socket.emit('invalid_token')
|
||||||
}
|
}
|
||||||
var client = this.clients[socket.id]
|
var client = this.clients[socket.id]
|
||||||
|
|
||||||
|
if (client.user !== undefined) {
|
||||||
|
Logger.debug(`[Server] Authenticating socket client already has user`, client.user)
|
||||||
|
}
|
||||||
|
|
||||||
client.user = user
|
client.user = user
|
||||||
|
|
||||||
if (!client.user.toJSONForBrowser) {
|
if (!client.user.toJSONForBrowser) {
|
||||||
@ -462,7 +489,8 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.broadcast.emit('user_online', client.user.toJSONForPublic(this.streamManager.streams))
|
Logger.debug(`[Server] User Online ${client.user.username}`)
|
||||||
|
this.io.emit('user_online', client.user.toJSONForPublic(this.streamManager.streams))
|
||||||
|
|
||||||
user.lastSeen = Date.now()
|
user.lastSeen = Date.now()
|
||||||
await this.db.updateEntity('user', user)
|
await this.db.updateEntity('user', user)
|
||||||
@ -477,6 +505,9 @@ class Server {
|
|||||||
librariesScanning: this.scanner.librariesScanning,
|
librariesScanning: this.scanner.librariesScanning,
|
||||||
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
||||||
}
|
}
|
||||||
|
if (user.type === 'root') {
|
||||||
|
initialPayload.usersOnline = this.usersOnline
|
||||||
|
}
|
||||||
client.socket.emit('init', initialPayload)
|
client.socket.emit('init', initialPayload)
|
||||||
|
|
||||||
// Setup log listener for root user
|
// Setup log listener for root user
|
||||||
|
@ -5,9 +5,11 @@ const fs = require('fs-extra')
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|
||||||
class StreamManager {
|
class StreamManager {
|
||||||
constructor(db, MetadataPath) {
|
constructor(db, MetadataPath, emitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
|
|
||||||
|
this.emitter = emitter
|
||||||
|
|
||||||
this.MetadataPath = MetadataPath
|
this.MetadataPath = MetadataPath
|
||||||
this.streams = []
|
this.streams = []
|
||||||
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
||||||
@ -112,7 +114,7 @@ class StreamManager {
|
|||||||
var stream = await this.openStream(client, audiobook)
|
var stream = await this.openStream(client, audiobook)
|
||||||
this.db.updateUserStream(client.user.id, stream.id)
|
this.db.updateUserStream(client.user.id, stream.id)
|
||||||
|
|
||||||
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
|
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeStreamRequest(socket) {
|
async closeStreamRequest(socket) {
|
||||||
@ -129,24 +131,7 @@ class StreamManager {
|
|||||||
client.stream = null
|
client.stream = null
|
||||||
this.db.updateUserStream(client.user.id, null)
|
this.db.updateUserStream(client.user.id, null)
|
||||||
|
|
||||||
socket.broadcast.emit('user_stream_update', client.user.toJSONForPublic(this.streams))
|
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
|
||||||
}
|
|
||||||
|
|
||||||
async openTestStream(StreamsPath, audiobookId) {
|
|
||||||
Logger.info('Open Stream Test Request', audiobookId)
|
|
||||||
// var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
|
||||||
// var stream = new StreamTest(StreamsPath, 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 }) {
|
streamUpdate(socket, { currentTime, streamId }) {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
class AudiobookProgress {
|
class AudiobookProgress {
|
||||||
constructor(progress) {
|
constructor(progress) {
|
||||||
this.audiobookId = null
|
this.audiobookId = null
|
||||||
this.audiobookTitle = null
|
|
||||||
|
this.id = null
|
||||||
this.totalDuration = null // seconds
|
this.totalDuration = null // seconds
|
||||||
this.progress = null // 0 to 1
|
this.progress = null // 0 to 1
|
||||||
this.currentTime = null // seconds
|
this.currentTime = null // seconds
|
||||||
@ -18,7 +19,6 @@ class AudiobookProgress {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
audiobookId: this.audiobookId,
|
audiobookId: this.audiobookId,
|
||||||
audiobookTitle: this.audiobookTitle,
|
|
||||||
totalDuration: this.totalDuration,
|
totalDuration: this.totalDuration,
|
||||||
progress: this.progress,
|
progress: this.progress,
|
||||||
currentTime: this.currentTime,
|
currentTime: this.currentTime,
|
||||||
@ -31,7 +31,6 @@ class AudiobookProgress {
|
|||||||
|
|
||||||
construct(progress) {
|
construct(progress) {
|
||||||
this.audiobookId = progress.audiobookId
|
this.audiobookId = progress.audiobookId
|
||||||
this.audiobookTitle = progress.audiobookTitle || null
|
|
||||||
this.totalDuration = progress.totalDuration
|
this.totalDuration = progress.totalDuration
|
||||||
this.progress = progress.progress
|
this.progress = progress.progress
|
||||||
this.currentTime = progress.currentTime
|
this.currentTime = progress.currentTime
|
||||||
@ -43,7 +42,6 @@ class AudiobookProgress {
|
|||||||
|
|
||||||
updateFromStream(stream) {
|
updateFromStream(stream) {
|
||||||
this.audiobookId = stream.audiobookId
|
this.audiobookId = stream.audiobookId
|
||||||
this.audiobookTitle = stream.audiobookTitle
|
|
||||||
this.totalDuration = stream.totalDuration
|
this.totalDuration = stream.totalDuration
|
||||||
this.progress = stream.clientProgress
|
this.progress = stream.clientProgress
|
||||||
this.currentTime = stream.clientCurrentTime
|
this.currentTime = stream.clientCurrentTime
|
||||||
@ -89,6 +87,9 @@ class AudiobookProgress {
|
|||||||
if (!this.startedAt) {
|
if (!this.startedAt) {
|
||||||
this.startedAt = Date.now()
|
this.startedAt = Date.now()
|
||||||
}
|
}
|
||||||
|
if (hasUpdates) {
|
||||||
|
this.lastUpdate = Date.now()
|
||||||
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -235,6 +235,17 @@ class Book {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parseGenresTag(genreTag) {
|
||||||
|
if (!genreTag || !genreTag.length) return []
|
||||||
|
var separators = ['/', '//', ';']
|
||||||
|
for (let i = 0; i < separators.length; i++) {
|
||||||
|
if (genreTag.includes(separators[i])) {
|
||||||
|
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [genreTag]
|
||||||
|
}
|
||||||
|
|
||||||
setDetailsFromFileMetadata(audioFileMetadata) {
|
setDetailsFromFileMetadata(audioFileMetadata) {
|
||||||
const MetadataMapArray = [
|
const MetadataMapArray = [
|
||||||
{
|
{
|
||||||
@ -260,14 +271,24 @@ class Book {
|
|||||||
{
|
{
|
||||||
tag: 'tagArtist',
|
tag: 'tagArtist',
|
||||||
key: 'author'
|
key: 'author'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagGenre',
|
||||||
|
key: 'genres'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
var updatePayload = {}
|
var updatePayload = {}
|
||||||
MetadataMapArray.forEach((mapping) => {
|
MetadataMapArray.forEach((mapping) => {
|
||||||
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
|
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
|
||||||
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
|
// Genres can contain multiple
|
||||||
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
if (mapping.key === 'genres') {
|
||||||
|
updatePayload[mapping.key] = this.parseGenresTag(audioFileMetadata[mapping.tag])
|
||||||
|
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key].join(',')}`)
|
||||||
|
} else {
|
||||||
|
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
|
||||||
|
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -16,6 +16,7 @@ class User {
|
|||||||
|
|
||||||
this.settings = {}
|
this.settings = {}
|
||||||
this.permissions = {}
|
this.permissions = {}
|
||||||
|
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.construct(user)
|
this.construct(user)
|
||||||
@ -37,6 +38,9 @@ class User {
|
|||||||
get canUpload() {
|
get canUpload() {
|
||||||
return !!this.permissions.upload && this.isActive
|
return !!this.permissions.upload && this.isActive
|
||||||
}
|
}
|
||||||
|
get canAccessAllLibraries() {
|
||||||
|
return !!this.permissions.accessAllLibraries && this.isActive
|
||||||
|
}
|
||||||
get hasPw() {
|
get hasPw() {
|
||||||
return !!this.pash && !!this.pash.length
|
return !!this.pash && !!this.pash.length
|
||||||
}
|
}
|
||||||
@ -59,7 +63,8 @@ class User {
|
|||||||
download: true,
|
download: true,
|
||||||
update: true,
|
update: true,
|
||||||
delete: this.type === 'root',
|
delete: this.type === 'root',
|
||||||
upload: this.type === 'root' || this.type === 'admin'
|
upload: this.type === 'root' || this.type === 'admin',
|
||||||
|
accessAllLibraries: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +93,8 @@ class User {
|
|||||||
lastSeen: this.lastSeen,
|
lastSeen: this.lastSeen,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
settings: this.settings,
|
settings: this.settings,
|
||||||
permissions: this.permissions
|
permissions: this.permissions,
|
||||||
|
librariesAccessible: [...this.librariesAccessible]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,10 +111,12 @@ class User {
|
|||||||
lastSeen: this.lastSeen,
|
lastSeen: this.lastSeen,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
settings: this.settings,
|
settings: this.settings,
|
||||||
permissions: this.permissions
|
permissions: this.permissions,
|
||||||
|
librariesAccessible: [...this.librariesAccessible]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Data broadcasted
|
||||||
toJSONForPublic(streams) {
|
toJSONForPublic(streams) {
|
||||||
var stream = this.stream && streams ? streams.find(s => s.id === this.stream) : null
|
var stream = this.stream && streams ? streams.find(s => s.id === this.stream) : null
|
||||||
return {
|
return {
|
||||||
@ -144,6 +152,11 @@ class User {
|
|||||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||||
// Upload permission added v1.1.13, make sure root user has upload permissions
|
// Upload permission added v1.1.13, make sure root user has upload permissions
|
||||||
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
|
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
|
||||||
|
|
||||||
|
// Library restriction permissions added v1.4.14, defaults to all libraries
|
||||||
|
if (this.permissions.accessAllLibraries === undefined) this.permissions.accessAllLibraries = true
|
||||||
|
|
||||||
|
this.librariesAccessible = (user.librariesAccessible || []).map(l => l)
|
||||||
}
|
}
|
||||||
|
|
||||||
update(payload) {
|
update(payload) {
|
||||||
@ -169,6 +182,18 @@ class User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Update accessible libraries
|
||||||
|
if (payload.librariesAccessible !== undefined) {
|
||||||
|
if (payload.librariesAccessible.length) {
|
||||||
|
if (payload.librariesAccessible.join(',') !== this.librariesAccessible.join(',')) {
|
||||||
|
hasUpdates = true
|
||||||
|
this.librariesAccessible = [...payload.librariesAccessible]
|
||||||
|
}
|
||||||
|
} else if (this.librariesAccessible.length > 0) {
|
||||||
|
hasUpdates = true
|
||||||
|
this.librariesAccessible = []
|
||||||
|
}
|
||||||
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,13 +205,13 @@ class User {
|
|||||||
this.audiobooks[stream.audiobookId].updateFromStream(stream)
|
this.audiobooks[stream.audiobookId].updateFromStream(stream)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAudiobookProgress(audiobookId, updatePayload) {
|
updateAudiobookProgress(audiobook, updatePayload) {
|
||||||
if (!this.audiobooks) this.audiobooks = {}
|
if (!this.audiobooks) this.audiobooks = {}
|
||||||
if (!this.audiobooks[audiobookId]) {
|
if (!this.audiobooks[audiobook.id]) {
|
||||||
this.audiobooks[audiobookId] = new AudiobookProgress()
|
this.audiobooks[audiobook.id] = new AudiobookProgress()
|
||||||
this.audiobooks[audiobookId].audiobookId = audiobookId
|
this.audiobooks[audiobook.id].audiobookId = audiobook.id
|
||||||
}
|
}
|
||||||
return this.audiobooks[audiobookId].update(updatePayload)
|
return this.audiobooks[audiobook.id].update(updatePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns Boolean If update was made
|
// Returns Boolean If update was made
|
||||||
@ -215,11 +240,11 @@ class User {
|
|||||||
return madeUpdates
|
return madeUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAudiobookProgress(audiobookId) {
|
resetAudiobookProgress(audiobook) {
|
||||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
if (!this.audiobooks || !this.audiobooks[audiobook.id]) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return this.updateAudiobookProgress(audiobookId, {
|
return this.updateAudiobookProgress(audiobook, {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
isRead: false,
|
isRead: false,
|
||||||
@ -236,5 +261,11 @@ class User {
|
|||||||
delete this.audiobooks[audiobookId]
|
delete this.audiobooks[audiobookId]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkCanAccessLibrary(libraryId) {
|
||||||
|
if (this.permissions.accessAllLibraries) return true
|
||||||
|
if (!this.librariesAccessible) return false
|
||||||
|
return this.librariesAccessible.includes(libraryId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = User
|
module.exports = User
|
@ -169,7 +169,6 @@ function parseTags(format, verbose) {
|
|||||||
file_tag_seriespart: tryGrabTag(format, 'series-part'),
|
file_tag_seriespart: tryGrabTag(format, 'series-part'),
|
||||||
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
||||||
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
|
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2'),
|
||||||
file_tag_genre: tryGrabTags(format, 'genre', 'genre')
|
|
||||||
}
|
}
|
||||||
for (const key in tags) {
|
for (const key in tags) {
|
||||||
if (!tags[key]) {
|
if (!tags[key]) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user