Support for libraries and folder mapping, updating static cover path, detect reader.txt

This commit is contained in:
advplyr 2021-10-04 22:11:42 -05:00
parent a590e795e3
commit 577f3bead9
43 changed files with 2548 additions and 768 deletions

View file

@ -7,13 +7,25 @@
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<!-- <div class="-mb-2">
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
<p class="text-sm text-gray-400 leading-3">My Library</p>
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
</div>
</div> -->
<!-- <div class="-mb-2 mr-6"> -->
<!-- <h1 class="text-base font-book leading-3 px-1">AudioBookshelf</h1> -->
<!-- <div class="bg-black bg-opacity-20 rounded-sm py-1 px-2 flex items-center border border-bg mt-1.5 cursor-pointer" @click="clickLibrary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-white text-opacity-50" 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 text-white text-opacity-70 leading-3 font-book pl-2">{{ libraryName }}</p>
</div> -->
<!-- </div> -->
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-3 flex items-center border border-bg 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-base leading-3 font-book pl-2">{{ libraryName }}</p>
</div>
<controls-global-search />
<div class="flex-grow" />
@ -66,11 +78,17 @@ export default {
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
libraryName() {
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
},
isHome() {
return this.$route.name === 'index'
return this.$route.name === 'library-library'
},
showBack() {
return this.$route.name !== 'library-id' && !this.isHome
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
},
user() {
return this.$store.state.user.user
@ -78,7 +96,6 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
@ -125,6 +142,9 @@ export default {
}
},
methods: {
clickLibrary() {
this.$store.commit('libraries/setShowModal', true)
},
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'

View file

@ -216,7 +216,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
updated() {

View file

@ -143,7 +143,7 @@ export default {
}
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}
},
mounted() {

View file

@ -1,7 +1,7 @@
<template>
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link to="/" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
@ -11,7 +11,7 @@
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
@ -21,7 +21,7 @@
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
@ -72,11 +72,14 @@ export default {
paramId() {
return this.$route.params ? this.$route.params.id || '' : ''
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
selectedClassName() {
return ''
},
homePage() {
return this.$route.name === 'index'
return this.$route.name === 'library-library'
}
},
methods: {},

View file

@ -79,7 +79,7 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl

View file

@ -1,7 +1,7 @@
<template>
<div class="relative">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?${groupType}=${groupEncode}`" class="cursor-pointer">
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
@ -48,6 +48,9 @@ export default {
}
},
computed: {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
_group() {
return this.group || {}
},

View file

@ -105,7 +105,7 @@ export default {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
if (!validCovers.length) {
this.noValidCovers = true
return

View file

@ -0,0 +1,45 @@
<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 class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View file

@ -0,0 +1,99 @@
<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 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>

View file

@ -4,7 +4,6 @@
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
@ -14,7 +13,6 @@
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>

View file

@ -0,0 +1,154 @@
<template>
<div class="w-full h-full px-4 py-2 mb-12">
<div class="flex items-center py-1 mb-2">
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
<p class="px-4 text-xl">{{ title }}</p>
</div>
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
<ui-text-input-with-label v-model="name" label="Library Name" />
<div class="w-full py-4">
<p class="px-1 text-sm font-semibold">Folders</p>
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
<span class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div>
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
</div>
</div>
</div>
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => null
},
processing: Boolean
},
data() {
return {
name: '',
folders: [],
showDirectoryPicker: false,
newLibraryName: ''
}
},
computed: {
title() {
if (this.showDirectoryPicker) return 'Choose a Folder'
return ''
},
folderPaths() {
return this.folders.map((f) => f.fullPath)
}
},
methods: {
removeFolder(folder) {
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
},
backArrowPress() {
if (this.showDirectoryPicker) {
this.showDirectoryPicker = false
}
},
init() {
this.name = this.library ? this.library.name : ''
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
this.showDirectoryPicker = false
},
selectFolder(fullPath) {
this.folders.push({ fullPath })
this.showDirectoryPicker = false
},
submit() {
if (this.library) {
this.updateLibrary()
} else {
this.createLibrary()
}
},
updateLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$patch(`/api/library/${this.library.id}`, newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" updated successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to update library')
}
this.$emit('update:processing', false)
})
},
createLibrary() {
if (!this.name) {
this.$toast.error('Library must have a name')
return
}
if (!this.folders.length) {
this.$toast.error('Library must have at least 1 path')
return
}
var newLibraryPayload = {
name: this.name,
folders: this.folders
}
this.$emit('update:processing', true)
this.$axios
.$post('/api/library', newLibraryPayload)
.then((res) => {
this.$emit('update:processing', false)
this.$emit('close')
this.$toast.success(`Library "${res.name}" created successfully`)
})
.catch((error) => {
console.error(error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
} else {
this.$toast.error('Failed to create library')
}
this.$emit('update:processing', false)
})
}
},
mounted() {
console.log('Mounted edit library')
this.init()
}
}
</script>

View file

@ -0,0 +1,165 @@
<template>
<div class="w-full h-full">
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
</div>
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
<div class="w-1/2 border-r border-bg">
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2">..</p>
</div>
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
</div>
</div>
<div class="w-1/2">
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
</div>
</div>
</div>
<div v-else-if="loadingFolders" class="py-12 text-center">
<p>Loading folders...</p>
</div>
<div v-else class="py-12 text-center">
<p class="text-lg mb-2">No Folders Available</p>
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
</div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center">
<div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
paths: {
type: Array,
default: () => []
}
},
data() {
return {
loadingFolders: false,
allFolders: [],
directories: [],
selectedPath: '',
selectedFullPath: '',
subdirs: [],
level: 0,
currentDir: null,
previousDir: null
}
},
computed: {
_directories() {
return this.directories.map((d) => {
console.log('Directories', d)
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var isSelected = d.path === this.selectedPath
var classes = []
if (isSelected) classes.push('dir-selected')
if (isUsed) classes.push('dir-used')
return {
isUsed,
isSelected,
className: classes.join(' '),
...d
}
})
},
_subdirs() {
return this.subdirs.map((d) => {
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
var classes = []
if (isUsed) classes.push('dir-used')
return {
isUsed,
className: classes.join(' '),
...d
}
})
}
},
methods: {
goBack() {
var splitPaths = this.selectedPath.split('\\').slice(1)
var prev = splitPaths.slice(0, -1).join('\\')
var currDirs = this.allFolders
for (let i = 0; i < splitPaths.length; i++) {
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
if (_dir && _dir.path.slice(1) === prev) {
this.directories = currDirs
this.selectDir(_dir)
return
} else if (_dir) {
currDirs = _dir.dirs
}
}
},
selectDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.subdirs = dir.dirs
},
selectSubDir(dir) {
if (dir.isUsed) return
this.selectedPath = dir.path
this.selectedFullPath = dir.fullPath
this.level = dir.level
this.directories = this.subdirs
this.subdirs = dir.dirs
},
selectFolder() {
if (!this.selectedPath) {
console.error('No Selected path')
return
}
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
return
}
this.$emit('select', this.selectedFullPath)
this.selectedPath = ''
this.selectedFullPath = ''
},
async init() {
this.loadingFolders = true
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
this.loadingFolders = false
this.directories = this.allFolders
this.subdirs = []
this.selectedPath = ''
this.selectedFullPath = ''
}
},
mounted() {
console.log('folder chooser mounted')
this.init()
}
}
</script>
<style>
.dir-item.dir-selected {
background-color: rgba(255, 255, 255, 0.1);
}
.dir-item.dir-used {
background-color: rgba(255, 25, 0, 0.1);
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<div class="w-full px-4 h-12 border border-white border-opacity-10 cursor-pointer flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false" @click="itemClicked">
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="mouseover ? 'text-opacity-90' : 'text-opacity-50'" 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>
<svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
<p class="text-xl font-book pl-4" :class="mouseover ? 'underline' : ''">{{ library.name }}</p>
<div class="flex-grow" />
<ui-btn v-show="mouseover && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
<span v-show="mouseover && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4" @click.stop="editClick">edit</span>
<span v-show="mouseover && showEdit && canDelete" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50'" @click.stop="deleteClick">delete</span>
</div>
</template>
<script>
export default {
props: {
library: {
type: Object,
default: () => {}
},
selected: Boolean,
showEdit: Boolean
},
data() {
return {
mouseover: false
}
},
computed: {
isMain() {
return this.library.id === 'main'
},
libraryScan() {
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
},
canEdit() {
return this.$store.getters['user/getIsRoot']
},
canDelete() {
return this.$store.getters['user/getIsRoot']
},
canScan() {
return this.$store.getters['user/getIsRoot']
}
},
methods: {
itemClicked() {
this.$emit('click', this.library)
},
editClick() {
this.$emit('edit', this.library)
},
deleteClick() {
if (this.isMain) return
this.$emit('delete', this.library)
},
scan() {
this.$root.socket.emit('scan', this.library.id)
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,77 @@
<template>
<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">Libraries</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<template v-for="library in libraries">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
</template>
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
</div>
</template>
<script>
export default {
data() {
return {
showLibraryModal: false,
selectedLibrary: null
}
},
computed: {
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.state.libraries.libraries
}
},
methods: {
async clickLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
},
deleteLibrary(library) {
if (library.id === 'main') return
// if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
// this.isDeletingUser = true
// this.$axios
// .$delete(`/api/user/${user.id}`)
// .then((data) => {
// this.isDeletingUser = false
// if (data.error) {
// this.$toast.error(data.error)
// } else {
// this.$toast.success('User deleted')
// }
// })
// .catch((error) => {
// console.error('Failed to delete user', error)
// this.$toast.error('Failed to delete user')
// this.isDeletingUser = false
// })
// }
},
clickAddLibrary() {
this.selectedLibrary = null
this.showLibraryModal = true
},
editLibrary(library) {
this.selectedLibrary = library
this.showLibraryModal = true
},
init() {}
},
mounted() {
this.init()
},
beforeDestroy() {}
}
</script>

View file

@ -0,0 +1,130 @@
<template>
<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">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
</div>
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<div class="text-center">
<table id="accounts">
<tr>
<th>Username</th>
<th>Account Type</th>
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
<td>{{ user.type }}</td>
<td class="text-sm font-mono">
{{ new Date(user.createdAt).toISOString() }}
</td>
<td>
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div>
</td>
</tr>
</table>
</div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
<script>
export default {
data() {
return {
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false
}
},
computed: {},
methods: {
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
}
},
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('user_removed', this.userRemoved)
}
}
}
</script>

View file

@ -0,0 +1,58 @@
<template>
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-1 bg-transparent border-b border-opacity-0 border-gray-400 focus:border-opacity-100 focus:outline-none" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
export default {
props: {
value: [String, Number],
placeholder: String,
readonly: Boolean,
type: {
type: String,
default: 'text'
},
disabled: Boolean
},
data() {
return {}
},
computed: {
inputValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) {
this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
<style scoped>
input {
border-style: inherit !important;
}
input:read-only {
background-color: #444;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
@ -35,7 +35,7 @@ export default {
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
}
},
methods: {

View file

@ -0,0 +1,33 @@
<template>
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
</template>
<script>
export default {
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.Vue-Toastification__close-button.cancel-scan-btn {
background-color: rgb(255, 82, 82);
color: white;
font-size: 0.9rem;
opacity: 1;
padding: 0px 10px;
border-radius: 6px;
font-weight: normal;
font-family: 'Open Sans';
margin-left: 10px;
opacity: 0.3;
}
.Vue-Toastification__close-button.cancel-scan-btn:hover {
background-color: rgb(235, 65, 65);
opacity: 1;
}
</style>

View file

@ -5,12 +5,15 @@
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-libraries-modal />
<modals-edit-modal />
<widgets-scan-alert />
<!-- <widgets-scan-alert /> -->
</div>
</template>
<script>
import CloseButton from '@/components/widgets/CloseButton'
export default {
middleware: 'authenticated',
data() {
@ -89,43 +92,62 @@ export default {
audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) {
this.$router.replace('/library')
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
}
}
this.$store.commit('audiobooks/remove', audiobook)
},
scanComplete({ scanType, results }) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', false)
if (results) {
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
}
} else {
this.$store.commit('setIsScanning', false)
if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
libraryAdded(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanStart(scanType) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library)
},
scanProgress({ scanType, progress }) {
if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
libraryRemoved(library) {
this.$store.commit('libraries/remove', library)
},
scanComplete(data) {
var message = `Scan "${data.name}" complete!`
if (data.results) {
var scanResultMsgs = []
var results = data.results
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) message += '\nEverything was up to date'
else message += '\n' + scanResultMsgs.join('\n')
} else {
this.$store.commit('setScanProgress', progress)
message = `Scan "${data.name}" was canceled`
}
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center' } }, true)
} else {
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
}
this.$store.commit('scanners/remove', data)
},
onScanToastCancel(id) {
console.log('On Scan Toast Cancel', id)
this.$root.socket.emit('cancel_scan', id)
},
scanStart(data) {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
console.log('Scan start toast id', data.toastId)
this.$store.commit('scanners/addUpdate', data)
},
scanProgress(data) {
console.log('scan progress', data)
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
}
this.$store.commit('scanners/addUpdate', data)
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
@ -226,6 +248,11 @@ export default {
this.socket.on('audiobook_added', this.audiobookAdded)
this.socket.on('audiobook_removed', this.audiobookRemoved)
// Library Listeners
this.socket.on('library_updated', this.libraryUpdated)
this.socket.on('library_added', this.libraryAdded)
this.socket.on('library_removed', this.libraryRemoved)
// User Listeners
this.socket.on('user_updated', this.userUpdated)
@ -270,6 +297,8 @@ export default {
},
mounted() {
this.initializeSocket()
this.$store.dispatch('libraries/load')
this.$store
.dispatch('checkForUpdate')
.then((res) => {

View file

@ -75,7 +75,9 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
},

View file

@ -1,43 +1,39 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
<tables-users-table />
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
<tables-libraries-table />
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-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">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="updatingServerSettings" />
<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>
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center">
<table id="accounts" class="mb-8">
<tr>
<th>Username</th>
<th>Account Type</th>
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
<td>{{ user.type }}</td>
<td class="text-sm font-mono">
{{ new Date(user.createdAt).toISOString() }}
</td>
<td>
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div>
</td>
</tr>
</table>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4">
<!-- <div class="py-4">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2">
<div class="py-2">
@ -81,7 +77,7 @@
</ui-tooltip>
</div>
</div>
</div>
</div> -->
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
@ -128,7 +124,7 @@
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
@ -143,10 +139,6 @@ export default {
return {
storeCoversInAudiobookDir: false,
isResettingAudiobooks: false,
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false,
newServerSettings: {},
updatingServerSettings: false
}
@ -166,6 +158,9 @@ export default {
coverDestinationTooltip() {
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
},
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'
},
saveMetadataTooltip() {
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
},
@ -232,7 +227,7 @@ export default {
})
},
scan() {
this.$root.socket.emit('scan')
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
},
scanCovers() {
this.$root.socket.emit('scan_covers')
@ -247,16 +242,6 @@ export default {
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata')
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
this.isResettingAudiobooks = true
@ -274,74 +259,13 @@ export default {
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
init() {
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
}
},
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
}
}
}
</script>

View file

@ -20,9 +20,11 @@
<script>
export default {
// asyncData({ redirect }) {
// redirect('/library')
// },
asyncData({ redirect, store }) {
var currentLibraryId = store.state.libraries.currentLibraryId
console.log('Redir', currentLibraryId)
redirect(`/library/${currentLibraryId}`)
},
data() {
return {}
},

View file

@ -12,7 +12,13 @@
<script>
export default {
async asyncData({ params, query, store, app }) {
async asyncData({ params, query, store, app, redirect }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect('/oops?message=Library not found')
}
if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
}
@ -20,7 +26,7 @@ export default {
var searchQuery = null
if (params.id === 'search' && query.query) {
searchQuery = query.query
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
searchResults = await app.$axios.$get(`/api/library/${libraryId}/audiobooks?q=${query.query}`).catch((error) => {
console.error('Search error', error)
return []
})

View file

@ -0,0 +1,37 @@
<template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, redirect }) {
var libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId)
if (!library) {
return redirect(`/oops?message=Library "${libraryId}" not found`)
}
return {
library
}
},
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

23
client/pages/oops.vue Normal file
View file

@ -0,0 +1,23 @@
<template>
<div class="w-screen h-screen overflow-hidden page">
<div class="flex h-1/3 items-center justify-center">
<h1 class="text-2xl">Oops... {{ message }}</h1>
</div>
</div>
</template>
<script>
export default {
asyncData({ query }) {
return {
message: query.message || ''
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -1,10 +1,12 @@
import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
import Path from 'path'
const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
export const state = () => ({
audiobooks: [],
loadedLibraryId: '',
lastLoad: 0,
listeners: [],
genres: [...STANDARD_GENRES],
@ -122,11 +124,12 @@ export const getters = {
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
var book = bookItem.book
if (!book || !book.cover || book.cover === placeholder) return placeholder
var cover = book.cover
// Absolute URL covers
// Absolute URL covers (should no longer be used)
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
// Server hosted covers
@ -135,6 +138,14 @@ export const getters = {
var bookLastUpdate = book.lastUpdate || Date.now()
var userToken = rootGetters['user/getToken']
// Map old covers to new format /s/book/{bookid}/*
if (cover.startsWith('\\local')) {
cover = cover.replace('local', `s\\book\\${bookItem.id}`)
if (cover.includes(bookItem.path + '\\')) { // Remove book path
cover = cover.replace(bookItem.path + '\\', '')
}
}
var url = new URL(cover, document.baseURI)
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
} catch (err) {
@ -152,18 +163,24 @@ export const actions = {
return false
}
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
var currentLibraryId = rootState.libraries.currentLibraryId
if (currentLibraryId === state.loadedLibraryId) {
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
}
commit('setLoadedLibrary', currentLibraryId)
this.$axios
.$get(`/api/audiobooks`)
.$get(`/api/library/${currentLibraryId}/audiobooks`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
})
.catch((error) => {
console.error('Failed', error)
@ -175,6 +192,9 @@ export const actions = {
}
export const mutations = {
setLoadedLibrary(state, val) {
state.loadedLibraryId = val
},
setLastLoad(state) {
state.lastLoad = Date.now()
},
@ -223,6 +243,10 @@ export const mutations = {
})
},
addUpdate(state, audiobook) {
if (audiobook.libraryId !== state.loadedLibraryId) {
return
}
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) {

View file

@ -9,10 +9,10 @@ export const state = () => ({
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
isScanning: false,
isScanningCovers: false,
scanProgress: null,
coverScanProgress: null,
// isScanning: false,
// isScanningCovers: false,
// scanProgress: null,
// coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false,
@ -113,20 +113,20 @@ export const mutations = {
setShowEditModal(state, val) {
state.showEditModal = val
},
setIsScanning(state, isScanning) {
state.isScanning = isScanning
},
setScanProgress(state, scanProgress) {
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = scanProgress
},
setIsScanningCovers(state, isScanningCovers) {
state.isScanningCovers = isScanningCovers
},
setCoverScanProgress(state, coverScanProgress) {
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
state.coverScanProgress = coverScanProgress
},
// setIsScanning(state, isScanning) {
// state.isScanning = isScanning
// },
// setScanProgress(state, scanProgress) {
// if (scanProgress && scanProgress.progress > 0) state.isScanning = true
// state.scanProgress = scanProgress
// },
// setIsScanningCovers(state, isScanningCovers) {
// state.isScanningCovers = isScanningCovers
// },
// setCoverScanProgress(state, coverScanProgress) {
// if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
// state.coverScanProgress = coverScanProgress
// },
setDeveloperMode(state, val) {
state.developerMode = val
},

144
client/store/libraries.js Normal file
View file

@ -0,0 +1,144 @@
export const state = () => ({
libraries: [],
lastLoad: 0,
listeners: [],
currentLibraryId: 'main',
showModal: false,
folders: [],
folderLastUpdate: 0
})
export const getters = {
getCurrentLibrary: state => {
return state.libraries.find(lib => lib.id === state.currentLibraryId)
}
}
export const actions = {
loadFolders({ state, commit }) {
if (state.folders.length) {
var lastCheck = Date.now() - state.folderLastUpdate
if (lastCheck < 1000 * 60 * 10) { // 10 minutes
// Folders up to date
return state.folders
}
}
console.log('Loading folders')
commit('setFoldersLastUpdate')
return this.$axios
.$get('/api/filesystem')
.then((res) => {
console.log('Settings folders', res)
commit('setFolders', res)
return res
})
.catch((error) => {
console.error('Failed to load dirs', error)
commit('setFolders', [])
return []
})
},
fetch({ state, commit, rootState }, libraryId) {
if (!rootState.user || !rootState.user.user) {
console.error('libraries/fetch - User not set')
return false
}
var library = state.libraries.find(lib => lib.id === libraryId)
if (library) {
commit('setCurrentLibrary', libraryId)
return library
}
return this.$axios
.$get(`/api/library/${libraryId}`)
.then((data) => {
commit('addUpdate', data)
commit('setCurrentLibrary', libraryId)
return data
})
.catch((error) => {
console.error('Failed', error)
return false
})
},
// Return true if calling load
load({ state, commit, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('libraries/load - User not set')
return false
}
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
this.$axios
.$get(`/api/libraries`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
})
return true
},
}
export const mutations = {
setFolders(state, folders) {
state.folders = folders
},
setFoldersLastUpdate(state) {
state.folderLastUpdate = Date.now()
},
setShowModal(state, val) {
state.showModal = val
},
setLastLoad(state) {
state.lastLoad = Date.now()
},
setCurrentLibrary(state, val) {
state.currentLibraryId = val
},
set(state, libraries) {
state.libraries = libraries
state.listeners.forEach((listener) => {
listener.meth()
})
},
addUpdate(state, library) {
var index = state.libraries.findIndex(a => a.id === library.id)
if (index >= 0) {
state.libraries.splice(index, 1, library)
} else {
state.libraries.push(library)
}
state.listeners.forEach((listener) => {
listener.meth()
})
},
remove(state, library) {
state.libraries = state.libraries.filter(a => a.id !== library.id)
state.listeners.forEach((listener) => {
listener.meth()
})
},
addListener(state, listener) {
var index = state.listeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.listeners.splice(index, 1, listener)
else state.listeners.push(listener)
},
removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId)
}
}

27
client/store/scanners.js Normal file
View file

@ -0,0 +1,27 @@
export const state = () => ({
libraryScans: []
})
export const getters = {
getLibraryScan: state => id => {
return state.libraryScans.find(ls => ls.id === id)
}
}
export const actions = {
}
export const mutations = {
addUpdate(state, data) {
var index = state.libraryScans.findIndex(lib => lib.id === data.id)
if (index >= 0) {
state.libraryScans.splice(index, 1, data)
} else {
state.libraryScans.push(data)
}
},
remove(state, data) {
state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id)
}
}