mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-05 00:18:30 +01:00
Support for libraries and folder mapping, updating static cover path, detect reader.txt
This commit is contained in:
parent
a590e795e3
commit
577f3bead9
@ -7,13 +7,25 @@
|
|||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||||
<!-- <div class="-mb-2">
|
<!-- <div class="-mb-2 mr-6"> -->
|
||||||
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
|
<!-- <h1 class="text-base font-book leading-3 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>
|
<!-- <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">
|
||||||
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
|
<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">
|
||||||
</div>
|
<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" />
|
||||||
</div> -->
|
</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 />
|
<controls-global-search />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
@ -66,11 +78,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibrary() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
|
},
|
||||||
|
libraryName() {
|
||||||
|
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||||
|
},
|
||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'index'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
showBack() {
|
||||||
return this.$route.name !== 'library-id' && !this.isHome
|
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||||
},
|
},
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
@ -78,7 +96,6 @@ export default {
|
|||||||
isRootUser() {
|
isRootUser() {
|
||||||
return this.$store.getters['user/getIsRoot']
|
return this.$store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
|
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user ? this.user.username : 'err'
|
||||||
},
|
},
|
||||||
@ -125,6 +142,9 @@ 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 || '/'
|
||||||
|
@ -216,7 +216,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan')
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
@ -143,7 +143,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan')
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
<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" />
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@ -11,7 +11,7 @@
|
|||||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@ -72,11 +72,14 @@ export default {
|
|||||||
paramId() {
|
paramId() {
|
||||||
return this.$route.params ? this.$route.params.id || '' : ''
|
return this.$route.params ? this.$route.params.id || '' : ''
|
||||||
},
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
selectedClassName() {
|
selectedClassName() {
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
homePage() {
|
homePage() {
|
||||||
return this.$route.name === 'index'
|
return this.$route.name === 'library-library'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
@ -79,7 +79,7 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
fullCoverUrl() {
|
||||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
|
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||||
},
|
},
|
||||||
cover() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.book.cover || this.placeholderUrl
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
<div class="rounded-sm h-full 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' }">
|
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
||||||
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
||||||
|
|
||||||
@ -48,6 +48,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
_group() {
|
_group() {
|
||||||
return this.group || {}
|
return this.group || {}
|
||||||
},
|
},
|
||||||
|
@ -105,7 +105,7 @@ export default {
|
|||||||
this.coverDiv.remove()
|
this.coverDiv.remove()
|
||||||
this.coverDiv = null
|
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) {
|
if (!validCovers.length) {
|
||||||
this.noValidCovers = true
|
this.noValidCovers = true
|
||||||
return
|
return
|
||||||
|
45
client/components/modals/EditLibraryModal.vue
Normal file
45
client/components/modals/EditLibraryModal.vue
Normal 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>
|
99
client/components/modals/LibrariesModal.vue
Normal file
99
client/components/modals/LibrariesModal.vue
Normal 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>
|
@ -4,7 +4,6 @@
|
|||||||
<div class="w-full border border-black-200 p-4 my-4">
|
<div class="w-full border border-black-200 p-4 my-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<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="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>
|
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -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.READY" class="text-success mb-2">Download Ready!</p>
|
||||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</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>
|
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||||
|
154
client/components/modals/libraries/EditLibrary.vue
Normal file
154
client/components/modals/libraries/EditLibrary.vue
Normal 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>
|
165
client/components/modals/libraries/FolderChooser.vue
Normal file
165
client/components/modals/libraries/FolderChooser.vue
Normal 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>
|
67
client/components/modals/libraries/LibraryItem.vue
Normal file
67
client/components/modals/libraries/LibraryItem.vue
Normal 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>
|
77
client/components/tables/LibrariesTable.vue
Normal file
77
client/components/tables/LibrariesTable.vue
Normal 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>
|
130
client/components/tables/UsersTable.vue
Normal file
130
client/components/tables/UsersTable.vue
Normal 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>
|
58
client/components/ui/EditableText.vue
Normal file
58
client/components/ui/EditableText.vue
Normal 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>
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
|
<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-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -35,7 +35,7 @@ export default {
|
|||||||
},
|
},
|
||||||
switchClassName() {
|
switchClassName() {
|
||||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
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: {
|
methods: {
|
||||||
|
33
client/components/widgets/CloseButton.vue
Normal file
33
client/components/widgets/CloseButton.vue
Normal 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>
|
@ -5,12 +5,15 @@
|
|||||||
<Nuxt />
|
<Nuxt />
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-stream-container ref="streamContainer" />
|
||||||
|
<modals-libraries-modal />
|
||||||
<modals-edit-modal />
|
<modals-edit-modal />
|
||||||
<widgets-scan-alert />
|
<!-- <widgets-scan-alert /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import CloseButton from '@/components/widgets/CloseButton'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
middleware: 'authenticated',
|
middleware: 'authenticated',
|
||||||
data() {
|
data() {
|
||||||
@ -89,43 +92,62 @@ export default {
|
|||||||
audiobookRemoved(audiobook) {
|
audiobookRemoved(audiobook) {
|
||||||
if (this.$route.name.startsWith('audiobook')) {
|
if (this.$route.name.startsWith('audiobook')) {
|
||||||
if (this.$route.params.id === audiobook.id) {
|
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)
|
this.$store.commit('audiobooks/remove', audiobook)
|
||||||
},
|
},
|
||||||
scanComplete({ scanType, results }) {
|
libraryAdded(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
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'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scanStart(scanType) {
|
libraryUpdated(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
this.$store.commit('setIsScanningCovers', true)
|
|
||||||
} else {
|
|
||||||
this.$store.commit('setIsScanning', true)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
scanProgress({ scanType, progress }) {
|
libraryRemoved(library) {
|
||||||
if (scanType === 'covers') {
|
this.$store.commit('libraries/remove', library)
|
||||||
this.$store.commit('setCoverScanProgress', progress)
|
},
|
||||||
|
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 {
|
} 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) {
|
userUpdated(user) {
|
||||||
if (this.$store.state.user.user.id === user.id) {
|
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_added', this.audiobookAdded)
|
||||||
this.socket.on('audiobook_removed', this.audiobookRemoved)
|
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
|
// User Listeners
|
||||||
this.socket.on('user_updated', this.userUpdated)
|
this.socket.on('user_updated', this.userUpdated)
|
||||||
|
|
||||||
@ -270,6 +297,8 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('checkForUpdate')
|
.dispatch('checkForUpdate')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
@ -75,7 +75,9 @@ module.exports = {
|
|||||||
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
'/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' : '/' }
|
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -1,43 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
<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="w-full max-w-4xl mx-auto">
|
||||||
<div class="flex items-center mb-2">
|
<tables-users-table />
|
||||||
<h1 class="text-2xl">Users</h1>
|
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||||
<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-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>
|
</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>
|
||||||
|
|
||||||
<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>
|
<p class="text-2xl">Scanner</p>
|
||||||
<div class="flex items-start py-2">
|
<div class="flex items-start py-2">
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
@ -81,7 +77,7 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<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>
|
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||||
|
|
||||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -143,10 +139,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
storeCoversInAudiobookDir: false,
|
storeCoversInAudiobookDir: false,
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
users: [],
|
|
||||||
selectedAccount: null,
|
|
||||||
showAccountModal: false,
|
|
||||||
isDeletingUser: false,
|
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
updatingServerSettings: false
|
updatingServerSettings: false
|
||||||
}
|
}
|
||||||
@ -166,6 +158,9 @@ export default {
|
|||||||
coverDestinationTooltip() {
|
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.'
|
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() {
|
saveMetadataTooltip() {
|
||||||
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||||
},
|
},
|
||||||
@ -232,7 +227,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan')
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
},
|
},
|
||||||
scanCovers() {
|
scanCovers() {
|
||||||
this.$root.socket.emit('scan_covers')
|
this.$root.socket.emit('scan_covers')
|
||||||
@ -247,16 +242,6 @@ export default {
|
|||||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||||
this.$root.socket.emit('save_metadata')
|
this.$root.socket.emit('save_metadata')
|
||||||
},
|
},
|
||||||
loadUsers() {
|
|
||||||
this.$axios
|
|
||||||
.$get('/api/users')
|
|
||||||
.then((users) => {
|
|
||||||
this.users = users
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
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?')) {
|
||||||
this.isResettingAudiobooks = true
|
this.isResettingAudiobooks = true
|
||||||
@ -274,74 +259,13 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickAddUser() {
|
init() {
|
||||||
this.selectedAccount = null
|
|
||||||
this.showAccountModal = true
|
|
||||||
},
|
|
||||||
editUser(user) {
|
|
||||||
this.selectedAccount = user
|
|
||||||
this.showAccountModal = true
|
|
||||||
},
|
|
||||||
deleteUserClick(user) {
|
|
||||||
if (this.isDeletingUser) return
|
|
||||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
|
||||||
this.isDeletingUser = true
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/user/${user.id}`)
|
|
||||||
.then((data) => {
|
|
||||||
this.isDeletingUser = false
|
|
||||||
if (data.error) {
|
|
||||||
this.$toast.error(data.error)
|
|
||||||
} else {
|
|
||||||
this.$toast.success('User deleted')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to delete user', error)
|
|
||||||
this.$toast.error('Failed to delete user')
|
|
||||||
this.isDeletingUser = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
addUpdateUser(user) {
|
|
||||||
if (!this.users) return
|
|
||||||
var index = this.users.findIndex((u) => u.id === user.id)
|
|
||||||
if (index >= 0) {
|
|
||||||
this.users.splice(index, 1, user)
|
|
||||||
} else {
|
|
||||||
this.users.push(user)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userRemoved(user) {
|
|
||||||
this.users = this.users.filter((u) => u.id !== user.id)
|
|
||||||
},
|
|
||||||
init(attempts = 0) {
|
|
||||||
if (!this.$root.socket) {
|
|
||||||
if (attempts > 10) {
|
|
||||||
return console.error('Failed to setup socket listeners')
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
this.init(++attempts)
|
|
||||||
}, 250)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
|
||||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
|
||||||
this.$root.socket.on('user_removed', this.userRemoved)
|
|
||||||
|
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadUsers()
|
|
||||||
this.init()
|
this.init()
|
||||||
},
|
|
||||||
beforeDestroy() {
|
|
||||||
if (this.$root.socket) {
|
|
||||||
this.$root.socket.off('user_added', this.newUserAdded)
|
|
||||||
this.$root.socket.off('user_updated', this.userUpdated)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -20,9 +20,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
// asyncData({ redirect }) {
|
asyncData({ redirect, store }) {
|
||||||
// redirect('/library')
|
var currentLibraryId = store.state.libraries.currentLibraryId
|
||||||
// },
|
console.log('Redir', currentLibraryId)
|
||||||
|
redirect(`/library/${currentLibraryId}`)
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
@ -12,7 +12,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
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) {
|
if (query.filter) {
|
||||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||||
}
|
}
|
||||||
@ -20,7 +26,7 @@ export default {
|
|||||||
var searchQuery = null
|
var searchQuery = null
|
||||||
if (params.id === 'search' && query.query) {
|
if (params.id === 'search' && query.query) {
|
||||||
searchQuery = 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)
|
console.error('Search error', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
37
client/pages/library/_library/index.vue
Normal file
37
client/pages/library/_library/index.vue
Normal 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
23
client/pages/oops.vue
Normal 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>
|
@ -1,10 +1,12 @@
|
|||||||
import { sort } from '@/assets/fastSort'
|
import { sort } from '@/assets/fastSort'
|
||||||
import { decode } from '@/plugins/init.client'
|
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']
|
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 = () => ({
|
export const state = () => ({
|
||||||
audiobooks: [],
|
audiobooks: [],
|
||||||
|
loadedLibraryId: '',
|
||||||
lastLoad: 0,
|
lastLoad: 0,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
genres: [...STANDARD_GENRES],
|
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))
|
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)
|
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
|
if (!book || !book.cover || book.cover === placeholder) return placeholder
|
||||||
var cover = book.cover
|
var cover = book.cover
|
||||||
|
|
||||||
// Absolute URL covers
|
// Absolute URL covers (should no longer be used)
|
||||||
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
|
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
|
||||||
|
|
||||||
// Server hosted covers
|
// Server hosted covers
|
||||||
@ -135,6 +138,14 @@ export const getters = {
|
|||||||
var bookLastUpdate = book.lastUpdate || Date.now()
|
var bookLastUpdate = book.lastUpdate || Date.now()
|
||||||
var userToken = rootGetters['user/getToken']
|
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)
|
var url = new URL(cover, document.baseURI)
|
||||||
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
|
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -152,18 +163,24 @@ export const actions = {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't load again if already loaded in the last 5 minutes
|
var currentLibraryId = rootState.libraries.currentLibraryId
|
||||||
var lastLoadDiff = Date.now() - state.lastLoad
|
|
||||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
if (currentLibraryId === state.loadedLibraryId) {
|
||||||
// Already up to date
|
// Don't load again if already loaded in the last 5 minutes
|
||||||
return false
|
var lastLoadDiff = Date.now() - state.lastLoad
|
||||||
|
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||||
|
// Already up to date
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
commit('setLoadedLibrary', currentLibraryId)
|
||||||
|
|
||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/audiobooks`)
|
.$get(`/api/library/${currentLibraryId}/audiobooks`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
commit('set', data)
|
commit('set', data)
|
||||||
commit('setLastLoad')
|
commit('setLastLoad')
|
||||||
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@ -175,6 +192,9 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
setLoadedLibrary(state, val) {
|
||||||
|
state.loadedLibraryId = val
|
||||||
|
},
|
||||||
setLastLoad(state) {
|
setLastLoad(state) {
|
||||||
state.lastLoad = Date.now()
|
state.lastLoad = Date.now()
|
||||||
},
|
},
|
||||||
@ -223,6 +243,10 @@ export const mutations = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
addUpdate(state, audiobook) {
|
addUpdate(state, audiobook) {
|
||||||
|
if (audiobook.libraryId !== state.loadedLibraryId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||||
var origAudiobook = null
|
var origAudiobook = null
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
@ -9,10 +9,10 @@ export const state = () => ({
|
|||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
selectedAudiobook: null,
|
selectedAudiobook: null,
|
||||||
playOnLoad: false,
|
playOnLoad: false,
|
||||||
isScanning: false,
|
// isScanning: false,
|
||||||
isScanningCovers: false,
|
// isScanningCovers: false,
|
||||||
scanProgress: null,
|
// scanProgress: null,
|
||||||
coverScanProgress: null,
|
// coverScanProgress: null,
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
selectedAudiobooks: [],
|
selectedAudiobooks: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
@ -113,20 +113,20 @@ export const mutations = {
|
|||||||
setShowEditModal(state, val) {
|
setShowEditModal(state, val) {
|
||||||
state.showEditModal = val
|
state.showEditModal = val
|
||||||
},
|
},
|
||||||
setIsScanning(state, isScanning) {
|
// setIsScanning(state, isScanning) {
|
||||||
state.isScanning = isScanning
|
// state.isScanning = isScanning
|
||||||
},
|
// },
|
||||||
setScanProgress(state, scanProgress) {
|
// setScanProgress(state, scanProgress) {
|
||||||
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
// if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
||||||
state.scanProgress = scanProgress
|
// state.scanProgress = scanProgress
|
||||||
},
|
// },
|
||||||
setIsScanningCovers(state, isScanningCovers) {
|
// setIsScanningCovers(state, isScanningCovers) {
|
||||||
state.isScanningCovers = isScanningCovers
|
// state.isScanningCovers = isScanningCovers
|
||||||
},
|
// },
|
||||||
setCoverScanProgress(state, coverScanProgress) {
|
// setCoverScanProgress(state, coverScanProgress) {
|
||||||
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
|
// if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
|
||||||
state.coverScanProgress = coverScanProgress
|
// state.coverScanProgress = coverScanProgress
|
||||||
},
|
// },
|
||||||
setDeveloperMode(state, val) {
|
setDeveloperMode(state, val) {
|
||||||
state.developerMode = val
|
state.developerMode = val
|
||||||
},
|
},
|
||||||
|
144
client/store/libraries.js
Normal file
144
client/store/libraries.js
Normal 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
27
client/store/scanners.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -4,9 +4,10 @@ const fs = require('fs-extra')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const User = require('./objects/User')
|
const User = require('./objects/User')
|
||||||
const { isObject } = require('./utils/index')
|
const { isObject } = require('./utils/index')
|
||||||
|
const Library = require('./objects/Library')
|
||||||
|
|
||||||
class ApiController {
|
class ApiController {
|
||||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, emitter, clientEmitter) {
|
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, watcher, emitter, clientEmitter) {
|
||||||
this.db = db
|
this.db = db
|
||||||
this.scanner = scanner
|
this.scanner = scanner
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
@ -14,6 +15,7 @@ class ApiController {
|
|||||||
this.rssFeeds = rssFeeds
|
this.rssFeeds = rssFeeds
|
||||||
this.downloadManager = downloadManager
|
this.downloadManager = downloadManager
|
||||||
this.coverController = coverController
|
this.coverController = coverController
|
||||||
|
this.watcher = watcher
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
this.MetadataPath = MetadataPath
|
this.MetadataPath = MetadataPath
|
||||||
@ -26,7 +28,14 @@ class ApiController {
|
|||||||
this.router.get('/find/covers', this.findCovers.bind(this))
|
this.router.get('/find/covers', this.findCovers.bind(this))
|
||||||
this.router.get('/find/:method', this.find.bind(this))
|
this.router.get('/find/:method', this.find.bind(this))
|
||||||
|
|
||||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
|
this.router.get('/libraries', this.getLibraries.bind(this))
|
||||||
|
this.router.get('/library/:id', this.getLibrary.bind(this))
|
||||||
|
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
|
||||||
|
this.router.patch('/library/:id', this.updateLibrary.bind(this))
|
||||||
|
this.router.get('/library/:id/audiobooks', this.getLibraryAudiobooks.bind(this))
|
||||||
|
this.router.post('/library', this.createNewLibrary.bind(this))
|
||||||
|
|
||||||
|
this.router.get('/audiobooks', this.getAudiobooks.bind(this)) // Old route should pass library id
|
||||||
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
||||||
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
||||||
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
||||||
@ -59,6 +68,8 @@ class ApiController {
|
|||||||
this.router.post('/feed', this.openRssFeed.bind(this))
|
this.router.post('/feed', this.openRssFeed.bind(this))
|
||||||
|
|
||||||
this.router.get('/download/:id', this.download.bind(this))
|
this.router.get('/download/:id', this.download.bind(this))
|
||||||
|
|
||||||
|
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
find(req, res) {
|
find(req, res) {
|
||||||
@ -77,6 +88,102 @@ class ApiController {
|
|||||||
res.json({ user: req.user })
|
res.json({ user: req.user })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLibraries(req, res) {
|
||||||
|
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
||||||
|
res.json(libraries)
|
||||||
|
}
|
||||||
|
|
||||||
|
getLibrary(req, res) {
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send('Library not found')
|
||||||
|
}
|
||||||
|
return res.json(library.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLibrary(req, res) {
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send('Library not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove library watcher
|
||||||
|
this.watcher.removeLibrary(library)
|
||||||
|
|
||||||
|
// Remove audiobooks in this library
|
||||||
|
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||||
|
Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`)
|
||||||
|
for (let i = 0; i < audiobooks.length; i++) {
|
||||||
|
await this.handleDeleteAudiobook(audiobooks[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
var libraryJson = library.toJSON()
|
||||||
|
await this.db.removeEntity('library', library.id)
|
||||||
|
this.emitter('library_removed', libraryJson)
|
||||||
|
return res.json(libraryJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLibrary(req, res) {
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send('Library not found')
|
||||||
|
}
|
||||||
|
var hasUpdates = library.update(req.body)
|
||||||
|
if (hasUpdates) {
|
||||||
|
// Update watcher
|
||||||
|
this.watcher.updateLibrary(library)
|
||||||
|
|
||||||
|
// Remove audiobooks no longer in library
|
||||||
|
var audiobooksToRemove = this.db.audiobooks.filter(ab => !library.checkFullPathInLibrary(ab.fullPath))
|
||||||
|
if (audiobooksToRemove.length) {
|
||||||
|
Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`)
|
||||||
|
for (let i = 0; i < audiobooksToRemove.length; i++) {
|
||||||
|
await this.handleDeleteAudiobook(audiobooksToRemove[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.db.updateEntity('library', library)
|
||||||
|
this.emitter('library_updated', library.toJSON())
|
||||||
|
}
|
||||||
|
return res.json(library.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
getLibraryAudiobooks(req, res) {
|
||||||
|
var libraryId = req.params.id
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(400).send('Library does not exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
var audiobooks = []
|
||||||
|
if (req.query.q) {
|
||||||
|
audiobooks = this.db.audiobooks.filter(ab => {
|
||||||
|
return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
|
||||||
|
}).map(ab => ab.toJSONMinified())
|
||||||
|
} else {
|
||||||
|
audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
|
||||||
|
}
|
||||||
|
res.json(audiobooks)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNewLibrary(req, res) {
|
||||||
|
var newLibraryPayload = {
|
||||||
|
...req.body
|
||||||
|
}
|
||||||
|
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
|
||||||
|
return res.status(500).send('Invalid request')
|
||||||
|
}
|
||||||
|
|
||||||
|
var library = new Library()
|
||||||
|
library.setData(newLibraryPayload)
|
||||||
|
await this.db.insertEntity('library', library)
|
||||||
|
this.emitter('library_added', library.toJSON())
|
||||||
|
|
||||||
|
// Add library watcher
|
||||||
|
this.watcher.addLibrary(library)
|
||||||
|
|
||||||
|
res.json(library)
|
||||||
|
}
|
||||||
|
|
||||||
getAudiobooks(req, res) {
|
getAudiobooks(req, res) {
|
||||||
var audiobooks = []
|
var audiobooks = []
|
||||||
if (req.query.q) {
|
if (req.query.q) {
|
||||||
@ -370,7 +477,7 @@ class ApiController {
|
|||||||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
||||||
account.createdAt = Date.now()
|
account.createdAt = Date.now()
|
||||||
var newUser = new User(account)
|
var newUser = new User(account)
|
||||||
var success = await this.db.insertUser(newUser)
|
var success = await this.db.insertEntity('user', newUser)
|
||||||
if (success) {
|
if (success) {
|
||||||
this.clientEmitter(req.user.id, 'user_added', newUser)
|
this.clientEmitter(req.user.id, 'user_added', newUser)
|
||||||
res.json({
|
res.json({
|
||||||
@ -492,5 +599,49 @@ class ApiController {
|
|||||||
genres: this.db.getGenres()
|
genres: this.db.getGenres()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
||||||
|
try {
|
||||||
|
var paths = await fs.readdir(dir)
|
||||||
|
|
||||||
|
var dirs = await Promise.all(paths.map(async dirname => {
|
||||||
|
var fullPath = Path.join(dir, dirname)
|
||||||
|
var path = Path.join(relpath, dirname)
|
||||||
|
|
||||||
|
var isDir = (await fs.lstat(fullPath)).isDirectory()
|
||||||
|
if (isDir && !excludedDirs.includes(dirname)) {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
dirname,
|
||||||
|
fullPath,
|
||||||
|
level,
|
||||||
|
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
dirs = dirs.filter(d => d)
|
||||||
|
return dirs
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('Failed to readdir', dir, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileSystemPaths(req, res) {
|
||||||
|
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc']
|
||||||
|
|
||||||
|
// Do not include existing mapped library paths in response
|
||||||
|
this.db.libraries.forEach(lib => {
|
||||||
|
lib.folders.forEach((folder) => {
|
||||||
|
excludedDirs.push(Path.basename(folder.fullPath))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
|
||||||
|
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
|
||||||
|
res.json(dirs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = ApiController
|
module.exports = ApiController
|
@ -20,7 +20,7 @@ class CoverController {
|
|||||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||||
return {
|
return {
|
||||||
fullPath: audiobook.fullPath,
|
fullPath: audiobook.fullPath,
|
||||||
relPath: Path.join('/local', audiobook.path)
|
relPath: '/s/book/' + audiobook.id
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
|
132
server/Db.js
132
server/Db.js
@ -4,20 +4,25 @@ const jwt = require('jsonwebtoken')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const Audiobook = require('./objects/Audiobook')
|
const Audiobook = require('./objects/Audiobook')
|
||||||
const User = require('./objects/User')
|
const User = require('./objects/User')
|
||||||
|
const Library = require('./objects/Library')
|
||||||
const ServerSettings = require('./objects/ServerSettings')
|
const ServerSettings = require('./objects/ServerSettings')
|
||||||
|
|
||||||
class Db {
|
class Db {
|
||||||
constructor(CONFIG_PATH) {
|
constructor(ConfigPath, AudiobookPath) {
|
||||||
this.ConfigPath = CONFIG_PATH
|
this.ConfigPath = ConfigPath
|
||||||
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
|
this.AudiobookPath = AudiobookPath
|
||||||
this.UsersPath = Path.join(CONFIG_PATH, 'users')
|
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
||||||
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
|
this.UsersPath = Path.join(ConfigPath, 'users')
|
||||||
|
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
||||||
|
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
||||||
|
|
||||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
|
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||||
|
|
||||||
this.users = []
|
this.users = []
|
||||||
|
this.libraries = []
|
||||||
this.audiobooks = []
|
this.audiobooks = []
|
||||||
this.settings = []
|
this.settings = []
|
||||||
|
|
||||||
@ -27,18 +32,14 @@ class Db {
|
|||||||
getEntityDb(entityName) {
|
getEntityDb(entityName) {
|
||||||
if (entityName === 'user') return this.usersDb
|
if (entityName === 'user') return this.usersDb
|
||||||
else if (entityName === 'audiobook') return this.audiobooksDb
|
else if (entityName === 'audiobook') return this.audiobooksDb
|
||||||
|
else if (entityName === 'library') return this.librariesDb
|
||||||
return this.settingsDb
|
return this.settingsDb
|
||||||
}
|
}
|
||||||
|
|
||||||
getEntityDbKey(entityName) {
|
|
||||||
if (entityName === 'user') return 'usersDb'
|
|
||||||
else if (entityName === 'audiobook') return 'audiobooksDb'
|
|
||||||
return 'settingsDb'
|
|
||||||
}
|
|
||||||
|
|
||||||
getEntityArrayKey(entityName) {
|
getEntityArrayKey(entityName) {
|
||||||
if (entityName === 'user') return 'users'
|
if (entityName === 'user') return 'users'
|
||||||
else if (entityName === 'audiobook') return 'audiobooks'
|
else if (entityName === 'audiobook') return 'audiobooks'
|
||||||
|
else if (entityName === 'library') return 'libraries'
|
||||||
return 'settings'
|
return 'settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +47,6 @@ class Db {
|
|||||||
return new User({
|
return new User({
|
||||||
id: 'root',
|
id: 'root',
|
||||||
type: 'root',
|
type: 'root',
|
||||||
|
|
||||||
username: 'root',
|
username: 'root',
|
||||||
pash: '',
|
pash: '',
|
||||||
stream: null,
|
stream: null,
|
||||||
@ -56,6 +56,20 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDefaultLibrary() {
|
||||||
|
var defaultLibrary = new Library()
|
||||||
|
defaultLibrary.setData({
|
||||||
|
id: 'main',
|
||||||
|
name: 'Main',
|
||||||
|
folder: { // Generates default folder
|
||||||
|
id: 'audiobooks',
|
||||||
|
fullPath: this.AudiobookPath,
|
||||||
|
libraryId: 'main'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return defaultLibrary
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.load()
|
await this.load()
|
||||||
|
|
||||||
@ -63,25 +77,33 @@ class Db {
|
|||||||
if (!this.users.find(u => u.type === 'root')) {
|
if (!this.users.find(u => u.type === 'root')) {
|
||||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||||
Logger.debug('Generated default token', token)
|
Logger.debug('Generated default token', token)
|
||||||
await this.insertUser(this.getDefaultUser(token))
|
await this.insertEntity('user', this.getDefaultUser(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.libraries.length) {
|
||||||
|
await this.insertEntity('library', this.getDefaultLibrary())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.serverSettings) {
|
if (!this.serverSettings) {
|
||||||
this.serverSettings = new ServerSettings()
|
this.serverSettings = new ServerSettings()
|
||||||
await this.insertSettings(this.serverSettings)
|
await this.insertEntity('settings', this.serverSettings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
||||||
this.audiobooks = results.data.map(a => new Audiobook(a))
|
this.audiobooks = results.data.map(a => new Audiobook(a))
|
||||||
Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
|
Logger.info(`[DB] ${this.audiobooks.length} Audiobooks Loaded`)
|
||||||
})
|
})
|
||||||
var p2 = this.usersDb.select(() => true).then((results) => {
|
var p2 = this.usersDb.select(() => true).then((results) => {
|
||||||
this.users = results.data.map(u => new User(u))
|
this.users = results.data.map(u => new User(u))
|
||||||
Logger.info(`[DB] Users Loaded ${this.users.length}`)
|
Logger.info(`[DB] ${this.users.length} Users Loaded`)
|
||||||
})
|
})
|
||||||
var p3 = this.settingsDb.select(() => true).then((results) => {
|
var p3 = this.librariesDb.select(() => true).then((results) => {
|
||||||
|
this.libraries = results.data.map(l => new Library(l))
|
||||||
|
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
|
||||||
|
})
|
||||||
|
var p4 = this.settingsDb.select(() => true).then((results) => {
|
||||||
if (results.data && results.data.length) {
|
if (results.data && results.data.length) {
|
||||||
this.settings = results.data
|
this.settings = results.data
|
||||||
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
||||||
@ -90,30 +112,21 @@ class Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all([p1, p2, p3])
|
await Promise.all([p1, p2, p3, p4])
|
||||||
}
|
}
|
||||||
|
|
||||||
insertSettings(settings) {
|
// insertAudiobook(audiobook) {
|
||||||
return this.settingsDb.insert([settings]).then((results) => {
|
// return this.insertAudiobooks([audiobook])
|
||||||
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
// }
|
||||||
this.settings = this.settings.concat(settings)
|
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Insert settings Failed ${error}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
insertAudiobook(audiobook) {
|
// insertAudiobooks(audiobooks) {
|
||||||
return this.insertAudiobooks([audiobook])
|
// return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||||
}
|
// Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||||
|
// this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||||
insertAudiobooks(audiobooks) {
|
// }).catch((error) => {
|
||||||
return this.audiobooksDb.insert(audiobooks).then((results) => {
|
// Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||||
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
// })
|
||||||
this.audiobooks = this.audiobooks.concat(audiobooks)
|
// }
|
||||||
}).catch((error) => {
|
|
||||||
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAudiobook(audiobook) {
|
updateAudiobook(audiobook) {
|
||||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||||
@ -125,16 +138,25 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
insertUser(user) {
|
// insertUser(user) {
|
||||||
return this.usersDb.insert([user]).then((results) => {
|
// return this.usersDb.insert([user]).then((results) => {
|
||||||
Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
// Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||||
this.users.push(user)
|
// this.users.push(user)
|
||||||
return true
|
// return true
|
||||||
}).catch((error) => {
|
// }).catch((error) => {
|
||||||
Logger.error(`[DB] Insert user Failed ${error}`)
|
// Logger.error(`[DB] Insert user Failed ${error}`)
|
||||||
return false
|
// return false
|
||||||
})
|
// })
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
// insertSettings(settings) {
|
||||||
|
// return this.settingsDb.insert([settings]).then((results) => {
|
||||||
|
// Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||||
|
// this.settings = this.settings.concat(settings)
|
||||||
|
// }).catch((error) => {
|
||||||
|
// Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
updateUserStream(userId, streamId) {
|
updateUserStream(userId, streamId) {
|
||||||
return this.usersDb.update((record) => record.id === userId, (user) => {
|
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||||
@ -153,6 +175,20 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insertEntity(entityName, entity) {
|
||||||
|
var entityDb = this.getEntityDb(entityName)
|
||||||
|
return entityDb.insert([entity]).then((results) => {
|
||||||
|
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
|
||||||
|
|
||||||
|
var arrayKey = this.getEntityArrayKey(entityName)
|
||||||
|
this[arrayKey].push(entity)
|
||||||
|
return true
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[DB] Failed to insert ${entityName}`, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
updateEntity(entityName, entity) {
|
updateEntity(entityName, entity) {
|
||||||
var entityDb = this.getEntityDb(entityName)
|
var entityDb = this.getEntityDb(entityName)
|
||||||
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
|
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
|
||||||
|
// Utils
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const BookFinder = require('./BookFinder')
|
const { version } = require('../package.json')
|
||||||
const Audiobook = require('./objects/Audiobook')
|
|
||||||
const audioFileScanner = require('./utils/audioFileScanner')
|
const audioFileScanner = require('./utils/audioFileScanner')
|
||||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||||
const { comparePaths, getIno } = require('./utils/index')
|
const { comparePaths, getIno } = require('./utils/index')
|
||||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||||
|
|
||||||
|
// Classes
|
||||||
|
const BookFinder = require('./BookFinder')
|
||||||
|
const Audiobook = require('./objects/Audiobook')
|
||||||
|
|
||||||
class Scanner {
|
class Scanner {
|
||||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
||||||
this.AudiobookPath = AUDIOBOOK_PATH
|
this.AudiobookPath = AUDIOBOOK_PATH
|
||||||
@ -20,6 +25,8 @@ class Scanner {
|
|||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
|
|
||||||
this.cancelScan = false
|
this.cancelScan = false
|
||||||
|
this.cancelLibraryScan = {}
|
||||||
|
this.librariesScanning = []
|
||||||
|
|
||||||
this.bookFinder = new BookFinder()
|
this.bookFinder = new BookFinder()
|
||||||
}
|
}
|
||||||
@ -32,7 +39,7 @@ class Scanner {
|
|||||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||||
return {
|
return {
|
||||||
fullPath: audiobook.fullPath,
|
fullPath: audiobook.fullPath,
|
||||||
relPath: Path.join('/local', audiobook.path)
|
relPath: '/s/book/' + audiobook.id
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
@ -97,164 +104,151 @@ class Scanner {
|
|||||||
return filesUpdated
|
return filesUpdated
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
async scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, forceAudioFileScan) {
|
||||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
// Always sync files and inode values
|
||||||
|
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
||||||
// inode value may change when using shared drives, update inode if matching path is found
|
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
||||||
// Note: inode will not change on rename
|
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
||||||
var hasUpdatedIno = false
|
hasUpdatedIno = true
|
||||||
if (!existingAudiobook) {
|
|
||||||
// check an audiobook exists with matching path, then update inodes
|
|
||||||
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
|
|
||||||
if (existingAudiobook) {
|
|
||||||
existingAudiobook.ino = audiobookData.ino
|
|
||||||
hasUpdatedIno = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingAudiobook) {
|
// TEMP: Check if is older audiobook and needs force rescan
|
||||||
// Always sync files and inode values
|
if (!forceAudioFileScan && (!existingAudiobook.scanVersion || existingAudiobook.checkHasOldCoverPath())) {
|
||||||
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
Logger.info(`[Scanner] Force rescan for "${existingAudiobook.title}" | Last scan v${existingAudiobook.scanVersion} | Old Cover Path ${!!existingAudiobook.checkHasOldCoverPath()}`)
|
||||||
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
forceAudioFileScan = true
|
||||||
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
}
|
||||||
hasUpdatedIno = true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ino is now set for every file in scandir
|
||||||
|
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||||
|
|
||||||
// TEMP: Check if is older audiobook and needs force rescan
|
// REMOVE: No valid audio files
|
||||||
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
|
// TODO: Label as incomplete, do not actually delete
|
||||||
Logger.info(`[Scanner] Re-Scanning all audio files for "${existingAudiobook.title}" (last scan <= 1.3.0)`)
|
if (!audiobookData.audioFiles.length) {
|
||||||
forceAudioFileScan = true
|
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||||
}
|
|
||||||
|
|
||||||
|
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||||
|
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||||
|
|
||||||
// REMOVE: No valid audio files
|
return ScanResult.REMOVED
|
||||||
// TODO: Label as incomplete, do not actually delete
|
}
|
||||||
if (!audiobookData.audioFiles.length) {
|
|
||||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
|
||||||
|
|
||||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
// Check for audio files that were removed
|
||||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||||
|
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||||
|
if (removedAudioFiles.length) {
|
||||||
|
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||||
|
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||||
|
}
|
||||||
|
|
||||||
return ScanResult.REMOVED
|
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||||
}
|
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||||
|
if (removedAudioTracks.length) {
|
||||||
|
Logger.error(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||||
|
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||||
|
}
|
||||||
|
|
||||||
// ino is now set for every file in scandir
|
// Check for new audio files and sync existing audio files
|
||||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
var newAudioFiles = []
|
||||||
|
var hasUpdatedAudioFiles = false
|
||||||
// Check for audio files that were removed
|
audiobookData.audioFiles.forEach((file) => {
|
||||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
if (existingAudioFile) { // Audio file exists, sync path (path may have been renamed)
|
||||||
if (removedAudioFiles.length) {
|
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
|
||||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
|
||||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
|
||||||
if (removedAudioTracks.length) {
|
|
||||||
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
|
||||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for new audio files and sync existing audio files
|
|
||||||
var newAudioFiles = []
|
|
||||||
var hasUpdatedAudioFiles = false
|
|
||||||
audiobookData.audioFiles.forEach((file) => {
|
|
||||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
|
||||||
if (existingAudioFile) { // Audio file exists, sync paths
|
|
||||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
|
||||||
hasUpdatedAudioFiles = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
|
||||||
if (audioFileWithMatchingPath) {
|
|
||||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
|
||||||
} else {
|
|
||||||
newAudioFiles.push(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Rescan audio file metadata
|
|
||||||
if (forceAudioFileScan) {
|
|
||||||
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
|
||||||
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
|
||||||
if (numAudioFilesUpdated > 0) {
|
|
||||||
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
|
||||||
hasUpdatedAudioFiles = true
|
hasUpdatedAudioFiles = true
|
||||||
|
}
|
||||||
// Use embedded cover art if audiobook has no cover
|
} else {
|
||||||
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
// New audio file, triple check for matching file path
|
||||||
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||||
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
if (audioFileWithMatchingPath) {
|
||||||
if (relativeDir) {
|
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
newAudioFiles.push(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Scan and add new audio files found and set tracks
|
// Rescan audio file metadata
|
||||||
if (newAudioFiles.length) {
|
if (forceAudioFileScan) {
|
||||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
||||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
||||||
|
if (numAudioFilesUpdated > 0) {
|
||||||
|
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
||||||
|
hasUpdatedAudioFiles = true
|
||||||
|
|
||||||
|
// Use embedded cover art if audiobook has no cover
|
||||||
|
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
||||||
|
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
||||||
|
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||||
|
if (relativeDir) {
|
||||||
|
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If after a scan no valid audio tracks remain
|
|
||||||
// TODO: Label as incomplete, do not actually delete
|
|
||||||
if (!existingAudiobook.tracks.length) {
|
|
||||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
|
||||||
|
|
||||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
|
||||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
|
||||||
return ScanResult.REMOVED
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
|
||||||
|
|
||||||
// Check that audio tracks are in sequential order with no gaps
|
|
||||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
|
||||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync other files (all files that are not audio files)
|
|
||||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, forceAudioFileScan)
|
|
||||||
if (otherFilesUpdated) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Syncs path and fullPath
|
|
||||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
|
||||||
hasUpdates = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// If audiobook was missing before, it is now found
|
|
||||||
if (existingAudiobook.isMissing) {
|
|
||||||
existingAudiobook.isMissing = false
|
|
||||||
hasUpdates = true
|
|
||||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save changes and notify users
|
|
||||||
if (hasUpdates) {
|
|
||||||
existingAudiobook.setChapters()
|
|
||||||
|
|
||||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
|
||||||
existingAudiobook.lastUpdate = Date.now()
|
|
||||||
await this.db.updateAudiobook(existingAudiobook)
|
|
||||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
|
||||||
|
|
||||||
return ScanResult.UPDATED
|
|
||||||
}
|
|
||||||
|
|
||||||
return ScanResult.UPTODATE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Check new audiobook
|
// Scan and add new audio files found and set tracks
|
||||||
|
if (newAudioFiles.length) {
|
||||||
|
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||||
|
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If after a scan no valid audio tracks remain
|
||||||
|
// TODO: Label as incomplete, do not actually delete
|
||||||
|
if (!existingAudiobook.tracks.length) {
|
||||||
|
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||||
|
|
||||||
|
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||||
|
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||||
|
return ScanResult.REMOVED
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasUpdates = hasUpdatedIno || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||||
|
|
||||||
|
// Check that audio tracks are in sequential order with no gaps
|
||||||
|
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||||
|
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync other files (all files that are not audio files) - Updates cover path
|
||||||
|
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, this.MetadataPath, forceAudioFileScan)
|
||||||
|
if (otherFilesUpdated) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncs path and fullPath
|
||||||
|
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If audiobook was missing before, it is now found
|
||||||
|
if (existingAudiobook.isMissing) {
|
||||||
|
existingAudiobook.isMissing = false
|
||||||
|
hasUpdates = true
|
||||||
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save changes and notify users
|
||||||
|
if (hasUpdates || !existingAudiobook.scanVersion) {
|
||||||
|
if (!existingAudiobook.scanVersion) {
|
||||||
|
Logger.debug(`[Scanner] No scan version "${existingAudiobook.title}" - updating`)
|
||||||
|
}
|
||||||
|
existingAudiobook.setChapters()
|
||||||
|
|
||||||
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||||
|
existingAudiobook.setLastScan(version)
|
||||||
|
await this.db.updateAudiobook(existingAudiobook)
|
||||||
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||||
|
|
||||||
|
return ScanResult.UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScanResult.UPTODATE
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanNewAudiobook(audiobookData) {
|
||||||
if (!audiobookData.audioFiles.length) {
|
if (!audiobookData.audioFiles.length) {
|
||||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||||
return ScanResult.NOTHING
|
return ScanResult.NOTHING
|
||||||
@ -262,15 +256,16 @@ class Scanner {
|
|||||||
|
|
||||||
var audiobook = new Audiobook()
|
var audiobook = new Audiobook()
|
||||||
audiobook.setData(audiobookData)
|
audiobook.setData(audiobookData)
|
||||||
|
|
||||||
|
// Scan audio files and set tracks, pulls metadata
|
||||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||||
if (!audiobook.tracks.length) {
|
if (!audiobook.tracks.length) {
|
||||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||||
return ScanResult.NOTHING
|
return ScanResult.NOTHING
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audiobook.hasDescriptionTextFile) {
|
// Look for desc.txt and reader.txt and update
|
||||||
await audiobook.saveDescriptionFromTextFile()
|
await audiobook.saveDataFromTextFiles()
|
||||||
}
|
|
||||||
|
|
||||||
if (audiobook.hasEmbeddedCoverArt) {
|
if (audiobook.hasEmbeddedCoverArt) {
|
||||||
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
||||||
@ -280,22 +275,79 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set book details from metadata pulled from audio files
|
||||||
audiobook.setDetailsFromFileMetadata()
|
audiobook.setDetailsFromFileMetadata()
|
||||||
|
|
||||||
|
// Check for gaps in track numbers
|
||||||
audiobook.checkUpdateMissingParts()
|
audiobook.checkUpdateMissingParts()
|
||||||
|
|
||||||
|
// Set chapters from audio files
|
||||||
audiobook.setChapters()
|
audiobook.setChapters()
|
||||||
|
|
||||||
|
audiobook.setLastScan(version)
|
||||||
|
|
||||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||||
await this.db.insertAudiobook(audiobook)
|
await this.db.insertEntity('audiobook', audiobook)
|
||||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||||
return ScanResult.ADDED
|
return ScanResult.ADDED
|
||||||
}
|
}
|
||||||
|
|
||||||
async scan(forceAudioFileScan = false) {
|
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
||||||
|
var scannerFindCovers = this.db.serverSettings.scannerFindCovers
|
||||||
|
var libraryId = audiobookData.libraryId
|
||||||
|
var audiobooksInLibrary = this.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||||
|
var existingAudiobook = audiobooksInLibrary.find(a => a.ino === audiobookData.ino)
|
||||||
|
|
||||||
|
// inode value may change when using shared drives, update inode if matching path is found
|
||||||
|
// Note: inode will not change on rename
|
||||||
|
var hasUpdatedIno = false
|
||||||
|
if (!existingAudiobook) {
|
||||||
|
// check an audiobook exists with matching path, then update inodes
|
||||||
|
existingAudiobook = audiobooksInLibrary.find(a => a.path === audiobookData.path)
|
||||||
|
if (existingAudiobook) {
|
||||||
|
existingAudiobook.ino = audiobookData.ino
|
||||||
|
hasUpdatedIno = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingAudiobook) {
|
||||||
|
return this.scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, forceAudioFileScan)
|
||||||
|
}
|
||||||
|
return this.scanNewAudiobook(audiobookData)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(libraryId, forceAudioFileScan = false) {
|
||||||
|
if (this.librariesScanning.includes(libraryId)) {
|
||||||
|
Logger.error(`[Scanner] Already scanning ${libraryId}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||||
|
if (!library) {
|
||||||
|
Logger.error(`[Scanner] Library not found for scan ${libraryId}`)
|
||||||
|
return
|
||||||
|
} else if (!library.folders.length) {
|
||||||
|
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitter('scan_start', {
|
||||||
|
id: libraryId,
|
||||||
|
name: library.name,
|
||||||
|
scanType: 'library',
|
||||||
|
folders: library.folders.length
|
||||||
|
})
|
||||||
|
Logger.info(`[Scanner] Starting scan of library "${library.name}" with ${library.folders.length} folders`)
|
||||||
|
|
||||||
|
this.librariesScanning.push(libraryId)
|
||||||
|
|
||||||
|
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||||
|
|
||||||
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
||||||
// TEMP - update ino for each audiobook
|
// TEMP - update ino for each audiobook
|
||||||
if (this.audiobooks.length) {
|
if (audiobooksInLibrary.length) {
|
||||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||||
var ab = this.audiobooks[i]
|
var ab = audiobooksInLibrary[i]
|
||||||
// Update ino if inos are not set
|
// Update ino if inos are not set
|
||||||
var shouldUpdateIno = ab.hasMissingIno
|
var shouldUpdateIno = ab.hasMissingIno
|
||||||
if (shouldUpdateIno) {
|
if (shouldUpdateIno) {
|
||||||
@ -309,13 +361,23 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scanStart = Date.now()
|
const scanStart = Date.now()
|
||||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
var audiobookDataFound = []
|
||||||
|
for (let i = 0; i < library.folders.length; i++) {
|
||||||
|
var folder = library.folders[i]
|
||||||
|
var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings)
|
||||||
|
Logger.debug(`[Scanner] ${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`)
|
||||||
|
audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder)
|
||||||
|
}
|
||||||
|
|
||||||
// Remove audiobooks with no inode
|
// Remove audiobooks with no inode
|
||||||
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
||||||
|
|
||||||
if (this.cancelScan) {
|
if (this.cancelLibraryScan[libraryId]) {
|
||||||
this.cancelScan = false
|
console.log('2', this.cancelLibraryScan)
|
||||||
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||||
|
delete this.cancelLibraryScan[libraryId]
|
||||||
|
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||||
|
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: null })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,8 +389,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for removed audiobooks
|
// Check for removed audiobooks
|
||||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||||
var audiobook = this.audiobooks[i]
|
var audiobook = audiobooksInLibrary[i]
|
||||||
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
|
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
|
||||||
if (!dataFound) {
|
if (!dataFound) {
|
||||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
||||||
@ -338,9 +400,13 @@ class Scanner {
|
|||||||
await this.db.updateAudiobook(audiobook)
|
await this.db.updateAudiobook(audiobook)
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
}
|
}
|
||||||
if (this.cancelScan) {
|
if (this.cancelLibraryScan[libraryId]) {
|
||||||
this.cancelScan = false
|
console.log('1', this.cancelLibraryScan)
|
||||||
return null
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||||
|
delete this.cancelLibraryScan[libraryId]
|
||||||
|
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||||
|
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,21 +419,26 @@ class Scanner {
|
|||||||
|
|
||||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||||
this.emitter('scan_progress', {
|
this.emitter('scan_progress', {
|
||||||
scanType: 'files',
|
id: libraryId,
|
||||||
|
name: library.name,
|
||||||
|
scanType: 'library',
|
||||||
progress: {
|
progress: {
|
||||||
total: audiobookDataFound.length,
|
total: audiobookDataFound.length,
|
||||||
done: i + 1,
|
done: i + 1,
|
||||||
progress
|
progress
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (this.cancelScan) {
|
if (this.cancelLibraryScan[libraryId]) {
|
||||||
this.cancelScan = false
|
console.log(this.cancelLibraryScan)
|
||||||
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||||
|
delete this.cancelLibraryScan[libraryId]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||||
return scanResults
|
this.librariesScanning = this.librariesScanning.filter(l => l !== libraryId)
|
||||||
|
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanAudiobookById(audiobookId) {
|
async scanAudiobookById(audiobookId) {
|
||||||
@ -376,78 +447,173 @@ class Scanner {
|
|||||||
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
||||||
return ScanResult.NOTHING
|
return ScanResult.NOTHING
|
||||||
}
|
}
|
||||||
|
const library = this.db.libraries.find(lib => lib.id === audiobook.libraryId)
|
||||||
|
if (!library) {
|
||||||
|
Logger.error(`[Scanner] Scan audiobook by id library not found "${audiobook.libraryId}"`)
|
||||||
|
return ScanResult.NOTHING
|
||||||
|
}
|
||||||
|
const folder = library.folders.find(f => f.id === audiobook.folderId)
|
||||||
|
if (!folder) {
|
||||||
|
Logger.error(`[Scanner] Scan audiobook by id folder not found "${audiobook.folderId}" in library "${library.name}"`)
|
||||||
|
return ScanResult.NOTHING
|
||||||
|
}
|
||||||
|
|
||||||
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
||||||
return this.scanAudiobook(audiobook.fullPath, true)
|
return this.scanAudiobook(folder, audiobook.fullPath, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanAudiobook(audiobookPath, forceAudioFileScan = false) {
|
async scanAudiobook(folder, audiobookFullPath, forceAudioFileScan = false) {
|
||||||
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
|
Logger.debug('[Scanner] scanAudiobook', audiobookFullPath)
|
||||||
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
|
var audiobookData = await getAudiobookFileData(folder, audiobookFullPath, this.db.serverSettings)
|
||||||
if (!audiobookData) {
|
if (!audiobookData) {
|
||||||
return ScanResult.NOTHING
|
return ScanResult.NOTHING
|
||||||
}
|
}
|
||||||
audiobookData.ino = await getIno(audiobookData.fullPath)
|
|
||||||
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files were modified in this directory, check it out
|
// Files were modified in this directory, check it out
|
||||||
async checkDir(dir) {
|
// async checkDir(dir) {
|
||||||
var exists = await fs.pathExists(dir)
|
// var exists = await fs.pathExists(dir)
|
||||||
if (!exists) {
|
// if (!exists) {
|
||||||
// Audiobook was deleted, TODO: Should confirm this better
|
// // Audiobook was deleted, TODO: Should confirm this better
|
||||||
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
|
// var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
|
||||||
if (audiobook) {
|
// if (audiobook) {
|
||||||
var audiobookJSON = audiobook.toJSONMinified()
|
// var audiobookJSON = audiobook.toJSONMinified()
|
||||||
await this.db.removeEntity('audiobook', audiobook.id)
|
// await this.db.removeEntity('audiobook', audiobook.id)
|
||||||
this.emitter('audiobook_removed', audiobookJSON)
|
// this.emitter('audiobook_removed', audiobookJSON)
|
||||||
return ScanResult.REMOVED
|
// return ScanResult.REMOVED
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Path inside audiobook was deleted, scan audiobook
|
||||||
|
// audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
|
||||||
|
// if (audiobook) {
|
||||||
|
// Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
|
||||||
|
// return this.scanAudiobook(audiobook.fullPath)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
||||||
|
// return ScanResult.NOTHING
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Check if this is a subdirectory of an audiobook
|
||||||
|
// var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
|
||||||
|
// if (audiobook) {
|
||||||
|
// Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
|
||||||
|
// return this.scanAudiobook(audiobook.fullPath)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Check if an audiobook is a subdirectory of this dir
|
||||||
|
// audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
|
||||||
|
// if (audiobook) {
|
||||||
|
// Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
|
||||||
|
// return ScanResult.NOTHING
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Must be a new audiobook
|
||||||
|
// Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
|
||||||
|
// return this.scanAudiobook(dir)
|
||||||
|
// }
|
||||||
|
|
||||||
|
async scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup) {
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||||
|
if (!library) {
|
||||||
|
Logger.error(`[Scanner] Library "${libraryId}" not found for scan library updates`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
var folder = library.folders.find(f => f.id === folderId)
|
||||||
|
if (!folder) {
|
||||||
|
Logger.error(`[Scanner] Folder "${folderId}" not found in library "${library.name}" for scan library updates`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
||||||
|
|
||||||
|
var bookGroupingResults = {}
|
||||||
|
for (const bookDir in fileUpdateBookGroup) {
|
||||||
|
var fullPath = Path.join(folder.fullPath, bookDir)
|
||||||
|
|
||||||
|
// Check if book dir group is already an audiobook or in a subdir of an audiobook
|
||||||
|
var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath))
|
||||||
|
if (existingAudiobook) {
|
||||||
|
|
||||||
|
// Is the audiobook exactly - check if was deleted
|
||||||
|
if (existingAudiobook.fullPath === fullPath) {
|
||||||
|
var exists = await fs.pathExists(fullPath)
|
||||||
|
if (!exists) {
|
||||||
|
Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`)
|
||||||
|
existingAudiobook.isMissing = true
|
||||||
|
existingAudiobook.lastUpdate = Date.now()
|
||||||
|
await this.db.updateAudiobook(existingAudiobook)
|
||||||
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||||
|
|
||||||
|
bookGroupingResults[bookDir] = ScanResult.REMOVED
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan audiobook for updates
|
||||||
|
Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`)
|
||||||
|
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook.fullPath)
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path inside audiobook was deleted, scan audiobook
|
// Check if an audiobook is a subdirectory of this dir
|
||||||
audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
|
var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath))
|
||||||
if (audiobook) {
|
if (childAudiobook) {
|
||||||
Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
|
Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`)
|
||||||
return this.scanAudiobook(audiobook.fullPath)
|
bookGroupingResults[bookDir] = ScanResult.NOTHING
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
||||||
return ScanResult.NOTHING
|
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, fullPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a subdirectory of an audiobook
|
return bookGroupingResults
|
||||||
var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
|
|
||||||
if (audiobook) {
|
|
||||||
Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
|
|
||||||
return this.scanAudiobook(audiobook.fullPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if an audiobook is a subdirectory of this dir
|
|
||||||
audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
|
|
||||||
if (audiobook) {
|
|
||||||
Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
|
|
||||||
return ScanResult.NOTHING
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be a new audiobook
|
|
||||||
Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
|
|
||||||
return this.scanAudiobook(dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Array of files that may have been renamed, removed or added
|
// Array of file update objects that may have been renamed, removed or added
|
||||||
async filesChanged(filepaths) {
|
async filesChanged(fileUpdates) {
|
||||||
if (!filepaths.length) return ScanResult.NOTHING
|
if (!fileUpdates.length) return null
|
||||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
|
||||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
|
||||||
|
|
||||||
var results = []
|
// Group files by folder
|
||||||
for (const dir in fileGroupings) {
|
var folderGroups = {}
|
||||||
Logger.debug(`[Scanner] Check dir ${dir}`)
|
fileUpdates.forEach((file) => {
|
||||||
var fullPath = Path.join(this.AudiobookPath, dir)
|
if (folderGroups[file.folderId]) {
|
||||||
var result = await this.checkDir(fullPath)
|
folderGroups[file.folderId].fileUpdates.push(file)
|
||||||
Logger.debug(`[Scanner] Check dir result ${result}`)
|
} else {
|
||||||
results.push(result)
|
folderGroups[file.folderId] = {
|
||||||
|
libraryId: file.libraryId,
|
||||||
|
folderId: file.folderId,
|
||||||
|
fileUpdates: [file]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const libraryScanResults = {}
|
||||||
|
|
||||||
|
// Group files by book
|
||||||
|
for (const folderId in folderGroups) {
|
||||||
|
var libraryId = folderGroups[folderId].libraryId
|
||||||
|
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||||
|
var fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true)
|
||||||
|
var folderScanResults = await this.scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup)
|
||||||
|
libraryScanResults[libraryId] = folderScanResults
|
||||||
}
|
}
|
||||||
return results
|
|
||||||
|
Logger.debug(`[Scanner] Finished scanning file changes, results:`, libraryScanResults)
|
||||||
|
return libraryScanResults
|
||||||
|
// var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||||
|
// var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
||||||
|
|
||||||
|
// var results = []
|
||||||
|
// for (const dir in fileGroupings) {
|
||||||
|
// Logger.debug(`[Scanner] Check dir ${dir}`)
|
||||||
|
// var fullPath = Path.join(this.AudiobookPath, dir)
|
||||||
|
// var result = await this.checkDir(fullPath)
|
||||||
|
// Logger.debug(`[Scanner] Check dir result ${result}`)
|
||||||
|
// results.push(result)
|
||||||
|
// }
|
||||||
|
// return results
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanCovers() {
|
async scanCovers() {
|
||||||
@ -495,7 +661,8 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
found,
|
found,
|
||||||
notFound
|
notFound,
|
||||||
|
failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
363
server/Server.js
363
server/Server.js
@ -6,8 +6,13 @@ const fs = require('fs-extra')
|
|||||||
const fileUpload = require('express-fileupload')
|
const fileUpload = require('express-fileupload')
|
||||||
const rateLimit = require('express-rate-limit')
|
const rateLimit = require('express-rate-limit')
|
||||||
|
|
||||||
const { ScanResult } = require('./utils/constants')
|
const { version } = require('../package.json')
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
const { ScanResult } = require('./utils/constants')
|
||||||
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
|
// Classes
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
const Watcher = require('./Watcher')
|
const Watcher = require('./Watcher')
|
||||||
const Scanner = require('./Scanner')
|
const Scanner = require('./Scanner')
|
||||||
@ -18,7 +23,7 @@ const StreamManager = require('./StreamManager')
|
|||||||
const RssFeeds = require('./RssFeeds')
|
const RssFeeds = require('./RssFeeds')
|
||||||
const DownloadManager = require('./DownloadManager')
|
const DownloadManager = require('./DownloadManager')
|
||||||
const CoverController = require('./CoverController')
|
const CoverController = require('./CoverController')
|
||||||
const Logger = require('./Logger')
|
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
@ -32,7 +37,7 @@ class Server {
|
|||||||
fs.ensureDirSync(METADATA_PATH)
|
fs.ensureDirSync(METADATA_PATH)
|
||||||
fs.ensureDirSync(AUDIOBOOK_PATH)
|
fs.ensureDirSync(AUDIOBOOK_PATH)
|
||||||
|
|
||||||
this.db = new Db(this.ConfigPath)
|
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||||
this.auth = new Auth(this.db)
|
this.auth = new Auth(this.db)
|
||||||
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)
|
||||||
@ -40,22 +45,24 @@ class Server {
|
|||||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||||
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.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.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||||
|
|
||||||
|
this.expressApp = null
|
||||||
this.server = null
|
this.server = null
|
||||||
this.io = null
|
this.io = null
|
||||||
|
|
||||||
this.clients = {}
|
this.clients = {}
|
||||||
|
|
||||||
this.isScanning = false
|
|
||||||
this.isScanningCovers = false
|
this.isScanningCovers = false
|
||||||
this.isInitialized = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get audiobooks() {
|
get audiobooks() {
|
||||||
return this.db.audiobooks
|
return this.db.audiobooks
|
||||||
}
|
}
|
||||||
|
get libraries() {
|
||||||
|
return this.db.libraries
|
||||||
|
}
|
||||||
get serverSettings() {
|
get serverSettings() {
|
||||||
return this.db.serverSettings
|
return this.db.serverSettings
|
||||||
}
|
}
|
||||||
@ -81,86 +88,8 @@ class Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async filesChanged(files) {
|
|
||||||
Logger.info('[Server]', files.length, 'Files Changed')
|
|
||||||
var result = await this.scanner.filesChanged(files)
|
|
||||||
Logger.debug('[Server] Files changed result', result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async scan(forceAudioFileScan = false) {
|
|
||||||
Logger.info('[Server] Starting Scan')
|
|
||||||
this.isScanning = true
|
|
||||||
this.isInitialized = true
|
|
||||||
this.emitter('scan_start', 'files')
|
|
||||||
var results = await this.scanner.scan(forceAudioFileScan)
|
|
||||||
this.isScanning = false
|
|
||||||
this.emitter('scan_complete', { scanType: 'files', results })
|
|
||||||
Logger.info('[Server] Scan complete')
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanAudiobook(socket, audiobookId) {
|
|
||||||
var result = await this.scanner.scanAudiobookById(audiobookId)
|
|
||||||
var scanResultName = ''
|
|
||||||
for (const key in ScanResult) {
|
|
||||||
if (ScanResult[key] === result) {
|
|
||||||
scanResultName = key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.emit('audiobook_scan_complete', scanResultName)
|
|
||||||
}
|
|
||||||
|
|
||||||
async scanCovers() {
|
|
||||||
Logger.info('[Server] Start cover scan')
|
|
||||||
this.isScanningCovers = true
|
|
||||||
this.emitter('scan_start', 'covers')
|
|
||||||
var results = await this.scanner.scanCovers()
|
|
||||||
this.isScanningCovers = false
|
|
||||||
this.emitter('scan_complete', { scanType: 'covers', results })
|
|
||||||
Logger.info('[Server] Cover scan complete')
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelScan() {
|
|
||||||
if (!this.isScanningCovers && !this.isScanning) return
|
|
||||||
this.scanner.cancelScan = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
|
||||||
async saveMetadata(socket, audiobookId = null) {
|
|
||||||
Logger.info('[Server] Starting save metadata files')
|
|
||||||
var response = await this.scanner.saveMetadata(audiobookId)
|
|
||||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
|
||||||
socket.emit('save_metadata_complete', response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove unused /metadata/books/{id} folders
|
|
||||||
async purgeMetadata() {
|
|
||||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
|
||||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
|
||||||
if (!booksMetadataExists) return
|
|
||||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
|
||||||
|
|
||||||
var purged = 0
|
|
||||||
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
|
||||||
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
|
||||||
if (!hasMatchingAudiobook) {
|
|
||||||
var folderPath = Path.join(booksMetadata, foldername)
|
|
||||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
|
||||||
|
|
||||||
await fs.remove(folderPath).then(() => {
|
|
||||||
purged++
|
|
||||||
}).catch((err) => {
|
|
||||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
if (purged > 0) {
|
|
||||||
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
|
||||||
}
|
|
||||||
return purged
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
Logger.info('[Server] Init')
|
Logger.info('[Server] Init v' + version)
|
||||||
await this.streamManager.ensureStreamsDir()
|
await this.streamManager.ensureStreamsDir()
|
||||||
await this.streamManager.removeOrphanStreams()
|
await this.streamManager.removeOrphanStreams()
|
||||||
await this.downloadManager.removeOrphanDownloads()
|
await this.downloadManager.removeOrphanDownloads()
|
||||||
@ -170,105 +99,66 @@ class Server {
|
|||||||
|
|
||||||
await this.purgeMetadata()
|
await this.purgeMetadata()
|
||||||
|
|
||||||
this.watcher.initWatcher()
|
this.watcher.initWatcher(this.libraries)
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
this.watcher.on('files', this.filesChanged.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
authMiddleware(req, res, next) {
|
|
||||||
this.auth.authMiddleware(req, res, next)
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleUpload(req, res) {
|
|
||||||
if (!req.user.canUpload) {
|
|
||||||
Logger.warn('User attempted to upload without permission', req.user)
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
var files = Object.values(req.files)
|
|
||||||
var title = req.body.title
|
|
||||||
var author = req.body.author
|
|
||||||
var series = req.body.series
|
|
||||||
|
|
||||||
if (!files.length || !title || !author) {
|
|
||||||
return res.json({
|
|
||||||
error: 'Invalid post data received'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var outputDirectory = ''
|
|
||||||
if (series && series.length && series !== 'null') {
|
|
||||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
|
||||||
} else {
|
|
||||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
var exists = await fs.pathExists(outputDirectory)
|
|
||||||
if (exists) {
|
|
||||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
|
||||||
return res.json({
|
|
||||||
error: `Directory "${outputDirectory}" already exists`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.ensureDir(outputDirectory)
|
|
||||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
var file = files[i]
|
|
||||||
|
|
||||||
var path = Path.join(outputDirectory, file.name)
|
|
||||||
await file.mv(path).catch((error) => {
|
|
||||||
Logger.error('Failed to move file', path, error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First time login rate limit is hit
|
|
||||||
loginLimitReached(req, res, options) {
|
|
||||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
|
||||||
options.message = 'Too many attempts. Login temporarily locked.'
|
|
||||||
}
|
|
||||||
|
|
||||||
getLoginRateLimiter() {
|
|
||||||
return rateLimit({
|
|
||||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
|
||||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
|
||||||
skipSuccessfulRequests: true,
|
|
||||||
onLimitReached: this.loginLimitReached
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
Logger.info('=== Starting Server ===')
|
Logger.info('=== Starting Server ===')
|
||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
this.expressApp = app
|
||||||
|
|
||||||
this.server = http.createServer(app)
|
this.server = http.createServer(app)
|
||||||
|
|
||||||
app.use(this.auth.cors)
|
app.use(this.auth.cors)
|
||||||
app.use(fileUpload())
|
app.use(fileUpload())
|
||||||
|
|
||||||
// Static path to generated nuxt
|
|
||||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
app.use(express.static(distPath))
|
|
||||||
app.use('/local', express.static(this.AudiobookPath))
|
|
||||||
} else {
|
|
||||||
app.use(express.static(this.AudiobookPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
|
||||||
|
|
||||||
app.use(express.static(this.MetadataPath))
|
|
||||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
// Dynamic routes are not generated on client
|
// Static path to generated nuxt
|
||||||
|
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||||
|
app.use(express.static(distPath))
|
||||||
|
|
||||||
|
// Old static path for covers
|
||||||
|
app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
|
||||||
|
|
||||||
|
// Metadata folder static path
|
||||||
|
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||||
|
|
||||||
|
// Static folder
|
||||||
|
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||||
|
|
||||||
|
// Static file routes
|
||||||
|
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
|
||||||
|
var library = this.libraries.find(lib => lib.id === req.params.library)
|
||||||
|
if (!library) return res.sendStatus(404)
|
||||||
|
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||||
|
if (!folder) return res.status(404).send('Folder not found')
|
||||||
|
|
||||||
|
var remainingPath = decodeURIComponent(req.params['0'])
|
||||||
|
|
||||||
|
var fullPath = Path.join(folder.fullPath, remainingPath)
|
||||||
|
res.sendFile(fullPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Book static file routes
|
||||||
|
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
|
||||||
|
var audiobook = this.audiobooks.find(ab => ab.id === req.params.id)
|
||||||
|
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
|
||||||
|
|
||||||
|
var remainingPath = decodeURIComponent(req.params['0'])
|
||||||
|
|
||||||
|
var fullPath = Path.join(audiobook.fullPath, remainingPath)
|
||||||
|
res.sendFile(fullPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Client routes
|
||||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
app.get('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
app.get('/library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
|
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
|
|
||||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||||
@ -368,6 +258,143 @@ class Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async filesChanged(fileUpdates) {
|
||||||
|
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||||
|
await this.scanner.filesChanged(fileUpdates)
|
||||||
|
// Logger.debug('[Server] Files changed result', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(libraryId, forceAudioFileScan = false) {
|
||||||
|
Logger.info('[Server] Starting Scan')
|
||||||
|
await this.scanner.scan(libraryId, forceAudioFileScan)
|
||||||
|
Logger.info('[Server] Scan complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanAudiobook(socket, audiobookId) {
|
||||||
|
var result = await this.scanner.scanAudiobookById(audiobookId)
|
||||||
|
var scanResultName = ''
|
||||||
|
for (const key in ScanResult) {
|
||||||
|
if (ScanResult[key] === result) {
|
||||||
|
scanResultName = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.emit('audiobook_scan_complete', scanResultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanCovers() {
|
||||||
|
Logger.info('[Server] Start cover scan')
|
||||||
|
this.isScanningCovers = true
|
||||||
|
// this.emitter('scan_start', 'covers')
|
||||||
|
var results = await this.scanner.scanCovers()
|
||||||
|
this.isScanningCovers = false
|
||||||
|
// this.emitter('scan_complete', { scanType: 'covers', results })
|
||||||
|
Logger.info('[Server] Cover scan complete')
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelScan(id) {
|
||||||
|
console.log('Cancel scan', id)
|
||||||
|
this.scanner.cancelLibraryScan[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||||
|
async saveMetadata(socket, audiobookId = null) {
|
||||||
|
Logger.info('[Server] Starting save metadata files')
|
||||||
|
var response = await this.scanner.saveMetadata(audiobookId)
|
||||||
|
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||||
|
socket.emit('save_metadata_complete', response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unused /metadata/books/{id} folders
|
||||||
|
async purgeMetadata() {
|
||||||
|
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||||
|
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||||
|
if (!booksMetadataExists) return
|
||||||
|
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||||
|
|
||||||
|
var purged = 0
|
||||||
|
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
||||||
|
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
||||||
|
if (!hasMatchingAudiobook) {
|
||||||
|
var folderPath = Path.join(booksMetadata, foldername)
|
||||||
|
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
||||||
|
|
||||||
|
await fs.remove(folderPath).then(() => {
|
||||||
|
purged++
|
||||||
|
}).catch((err) => {
|
||||||
|
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
if (purged > 0) {
|
||||||
|
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
||||||
|
}
|
||||||
|
return purged
|
||||||
|
}
|
||||||
|
|
||||||
|
authMiddleware(req, res, next) {
|
||||||
|
this.auth.authMiddleware(req, res, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpload(req, res) {
|
||||||
|
if (!req.user.canUpload) {
|
||||||
|
Logger.warn('User attempted to upload without permission', req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
var files = Object.values(req.files)
|
||||||
|
var title = req.body.title
|
||||||
|
var author = req.body.author
|
||||||
|
var series = req.body.series
|
||||||
|
|
||||||
|
if (!files.length || !title || !author) {
|
||||||
|
return res.json({
|
||||||
|
error: 'Invalid post data received'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputDirectory = ''
|
||||||
|
if (series && series.length && series !== 'null') {
|
||||||
|
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||||
|
} else {
|
||||||
|
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists = await fs.pathExists(outputDirectory)
|
||||||
|
if (exists) {
|
||||||
|
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||||
|
return res.json({
|
||||||
|
error: `Directory "${outputDirectory}" already exists`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.ensureDir(outputDirectory)
|
||||||
|
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
var file = files[i]
|
||||||
|
|
||||||
|
var path = Path.join(outputDirectory, file.name)
|
||||||
|
await file.mv(path).catch((error) => {
|
||||||
|
Logger.error('Failed to move file', path, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time login rate limit is hit
|
||||||
|
loginLimitReached(req, res, options) {
|
||||||
|
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||||
|
options.message = 'Too many attempts. Login temporarily locked.'
|
||||||
|
}
|
||||||
|
|
||||||
|
getLoginRateLimiter() {
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||||
|
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||||
|
skipSuccessfulRequests: true,
|
||||||
|
onLimitReached: this.loginLimitReached
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
logout(req, res) {
|
logout(req, res) {
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
@ -407,8 +434,6 @@ class Server {
|
|||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
serverSettings: this.serverSettings.toJSON(),
|
serverSettings: this.serverSettings.toJSON(),
|
||||||
isScanning: this.isScanning,
|
|
||||||
isInitialized: this.isInitialized,
|
|
||||||
audiobookPath: this.AudiobookPath,
|
audiobookPath: this.AudiobookPath,
|
||||||
metadataPath: this.MetadataPath,
|
metadataPath: this.MetadataPath,
|
||||||
configPath: this.ConfigPath,
|
configPath: this.ConfigPath,
|
||||||
|
@ -4,107 +4,184 @@ const Watcher = require('watcher')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
class FolderWatcher extends EventEmitter {
|
class FolderWatcher extends EventEmitter {
|
||||||
constructor(audiobookPath) {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.AudiobookPath = audiobookPath
|
this.paths = [] // Not used
|
||||||
this.folderMap = {}
|
this.pendingFiles = [] // Not used
|
||||||
this.watcher = null
|
|
||||||
|
|
||||||
this.pendingFiles = []
|
this.libraryWatchers = []
|
||||||
|
this.pendingFileUpdates = []
|
||||||
this.pendingDelay = 4000
|
this.pendingDelay = 4000
|
||||||
this.pendingTimeout = null
|
this.pendingTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
initWatcher() {
|
get pendingFilePaths() {
|
||||||
try {
|
return this.pendingFileUpdates.map(f => f.path)
|
||||||
Logger.info('[FolderWatcher] Initializing..')
|
}
|
||||||
this.watcher = new Watcher(this.AudiobookPath, {
|
|
||||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
buildLibraryWatcher(library) {
|
||||||
renameDetection: true,
|
if (this.libraryWatchers.find(w => w.id === library.id)) {
|
||||||
renameTimeout: 2000,
|
Logger.warn('[Watcher] Already watching library', library.name)
|
||||||
recursive: true,
|
return
|
||||||
ignoreInitial: true,
|
}
|
||||||
persistent: true
|
Logger.info(`[Watcher] Initializing watcher for "${library.name}"..`)
|
||||||
|
var folderPaths = library.folderPaths
|
||||||
|
var watcher = new Watcher(folderPaths, {
|
||||||
|
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||||
|
renameDetection: true,
|
||||||
|
renameTimeout: 2000,
|
||||||
|
recursive: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
persistent: true
|
||||||
|
})
|
||||||
|
watcher
|
||||||
|
.on('add', (path) => {
|
||||||
|
this.onNewFile(library.id, path)
|
||||||
|
}).on('change', (path) => {
|
||||||
|
// This is triggered from metadata changes, not what we want
|
||||||
|
// this.onFileUpdated(path)
|
||||||
|
}).on('unlink', path => {
|
||||||
|
this.onFileRemoved(library.id, path)
|
||||||
|
}).on('rename', (path, pathNext) => {
|
||||||
|
this.onRename(library.id, path, pathNext)
|
||||||
|
}).on('error', (error) => {
|
||||||
|
Logger.error(`[FolderWatcher] ${error}`)
|
||||||
|
}).on('ready', () => {
|
||||||
|
Logger.info('[FolderWatcher] Ready')
|
||||||
})
|
})
|
||||||
this.watcher
|
|
||||||
.on('add', (path) => {
|
this.libraryWatchers.push({
|
||||||
this.onNewFile(path)
|
id: library.id,
|
||||||
}).on('change', (path) => {
|
name: library.name,
|
||||||
// This is triggered from metadata changes, not what we want
|
folders: library.folders,
|
||||||
// this.onFileUpdated(path)
|
paths: library.folderPaths,
|
||||||
}).on('unlink', path => {
|
watcher
|
||||||
this.onFileRemoved(path)
|
})
|
||||||
}).on('rename', (path, pathNext) => {
|
}
|
||||||
this.onRename(path, pathNext)
|
|
||||||
}).on('error', (error) => {
|
initWatcher(libraries) {
|
||||||
Logger.error(`[FolderWatcher] ${error}`)
|
libraries.forEach((lib) => {
|
||||||
}).on('ready', () => {
|
this.buildLibraryWatcher(lib)
|
||||||
Logger.info('[FolderWatcher] Ready')
|
})
|
||||||
})
|
}
|
||||||
} catch (error) {
|
|
||||||
Logger.error('Chokidar watcher failed', error)
|
addLibrary(library) {
|
||||||
|
this.buildLibraryWatcher(library)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLibrary(library) {
|
||||||
|
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||||
|
if (libwatcher) {
|
||||||
|
libwatcher.name = library.name
|
||||||
|
|
||||||
|
var pathsToAdd = library.folderPaths.filter(path => !libwatcher.paths.includes(path))
|
||||||
|
if (pathsToAdd.length) {
|
||||||
|
Logger.info(`[Watcher] Adding paths to library watcher "${library.name}"`)
|
||||||
|
libwatcher.paths = library.folderPaths
|
||||||
|
libwatcher.folders = library.folders
|
||||||
|
libwatcher.watcher.watchPaths(pathsToAdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLibrary(library) {
|
||||||
|
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||||
|
if (libwatcher) {
|
||||||
|
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
||||||
|
libwatcher.watcher.close()
|
||||||
|
this.libraryWatchers = this.libraryWatchers.filter(lib => lib.id !== library.id)
|
||||||
|
} else {
|
||||||
|
Logger.error(`[Watcher] Library watcher not found for "${library.name}"`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
return this.watcher.close()
|
return this.libraryWatchers.map(lib => lib.watcher.close())
|
||||||
}
|
}
|
||||||
|
|
||||||
// After [pendingBatchDelay] seconds emit batch
|
onNewFile(libraryId, path) {
|
||||||
async onNewFile(path) {
|
Logger.debug('[Watcher] File Added', path)
|
||||||
if (this.pendingFiles.includes(path)) return
|
this.addFileUpdate(libraryId, path, 'added')
|
||||||
|
|
||||||
Logger.debug('FolderWatcher: New File', path)
|
|
||||||
|
|
||||||
var dir = Path.dirname(path)
|
|
||||||
if (dir === this.AudiobookPath) {
|
|
||||||
Logger.debug('New File added to root dir, ignoring it')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingFiles.push(path)
|
|
||||||
clearTimeout(this.pendingTimeout)
|
|
||||||
this.pendingTimeout = setTimeout(() => {
|
|
||||||
this.emit('files', this.pendingFiles.map(f => f))
|
|
||||||
this.pendingFiles = []
|
|
||||||
}, this.pendingDelay)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFileRemoved(path) {
|
onFileRemoved(libraryId, path) {
|
||||||
Logger.debug('[FolderWatcher] File Removed', path)
|
Logger.debug('[Watcher] File Removed', path)
|
||||||
|
this.addFileUpdate(libraryId, path, 'deleted')
|
||||||
|
// var dir = Path.dirname(path)
|
||||||
|
// if (dir === this.AudiobookPath) {
|
||||||
|
// Logger.debug('New File added to root dir, ignoring it')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
var dir = Path.dirname(path)
|
// this.pendingFiles.push(path)
|
||||||
if (dir === this.AudiobookPath) {
|
// clearTimeout(this.pendingTimeout)
|
||||||
Logger.debug('New File added to root dir, ignoring it')
|
// this.pendingTimeout = setTimeout(() => {
|
||||||
return
|
// this.emit('files', this.pendingFiles.map(f => f))
|
||||||
}
|
// this.pendingFiles = []
|
||||||
|
// }, this.pendingDelay)
|
||||||
this.pendingFiles.push(path)
|
|
||||||
clearTimeout(this.pendingTimeout)
|
|
||||||
this.pendingTimeout = setTimeout(() => {
|
|
||||||
this.emit('files', this.pendingFiles.map(f => f))
|
|
||||||
this.pendingFiles = []
|
|
||||||
}, this.pendingDelay)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onFileUpdated(path) {
|
onFileUpdated(path) {
|
||||||
Logger.debug('[FolderWatcher] Updated File', path)
|
Logger.debug('[Watcher] Updated File', path)
|
||||||
}
|
}
|
||||||
|
|
||||||
onRename(pathFrom, pathTo) {
|
onRename(libraryId, pathFrom, pathTo) {
|
||||||
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
|
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||||
|
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||||
|
// var dir = Path.dirname(pathTo)
|
||||||
|
// if (dir === this.AudiobookPath) {
|
||||||
|
// Logger.debug('New File added to root dir, ignoring it')
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
var dir = Path.dirname(pathTo)
|
// this.pendingFiles.push(pathTo)
|
||||||
if (dir === this.AudiobookPath) {
|
// clearTimeout(this.pendingTimeout)
|
||||||
Logger.debug('New File added to root dir, ignoring it')
|
// this.pendingTimeout = setTimeout(() => {
|
||||||
|
// this.emit('files', this.pendingFiles.map(f => f))
|
||||||
|
// this.pendingFiles = []
|
||||||
|
// }, this.pendingDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
addFileUpdate(libraryId, path, type) {
|
||||||
|
if (this.pendingFilePaths.includes(path)) return
|
||||||
|
|
||||||
|
// Get file library
|
||||||
|
var libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
|
||||||
|
if (!libwatcher) {
|
||||||
|
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pendingFiles.push(pathTo)
|
// Get file folder
|
||||||
|
var folder = libwatcher.folders.find(fold => path.startsWith(fold.fullPath))
|
||||||
|
if (!folder) {
|
||||||
|
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file was added to root directory
|
||||||
|
var dir = Path.dirname(path)
|
||||||
|
if (dir === folder.fullPath) {
|
||||||
|
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var relPath = path.replace(folder.fullPath, '')
|
||||||
|
Logger.debug(`[Watcher] New File in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
|
||||||
|
|
||||||
|
this.pendingFileUpdates.push({
|
||||||
|
path,
|
||||||
|
relPath,
|
||||||
|
folderId: folder.id,
|
||||||
|
libraryId,
|
||||||
|
type
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notify server of update after "pendingDelay"
|
||||||
clearTimeout(this.pendingTimeout)
|
clearTimeout(this.pendingTimeout)
|
||||||
this.pendingTimeout = setTimeout(() => {
|
this.pendingTimeout = setTimeout(() => {
|
||||||
this.emit('files', this.pendingFiles.map(f => f))
|
this.emit('files', this.pendingFileUpdates)
|
||||||
this.pendingFiles = []
|
this.pendingFileUpdates = []
|
||||||
}, this.pendingDelay)
|
}, this.pendingDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,9 +34,6 @@ class AudioFile {
|
|||||||
this.exclude = false
|
this.exclude = false
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
// TEMP: For forcing rescan
|
|
||||||
this.isOldAudioFile = false
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
this.construct(data)
|
this.construct(data)
|
||||||
}
|
}
|
||||||
@ -103,7 +100,6 @@ class AudioFile {
|
|||||||
// Old version of AudioFile used `tagAlbum` etc.
|
// Old version of AudioFile used `tagAlbum` etc.
|
||||||
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
||||||
if (isOldVersion) {
|
if (isOldVersion) {
|
||||||
this.isOldAudioFile = true
|
|
||||||
this.metadata = new AudioFileMetadata(data)
|
this.metadata = new AudioFileMetadata(data)
|
||||||
} else {
|
} else {
|
||||||
this.metadata = new AudioFileMetadata(data.metadata || {})
|
this.metadata = new AudioFileMetadata(data.metadata || {})
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
||||||
const { comparePaths, getIno } = require('../utils/index')
|
const { comparePaths, getIno } = require('../utils/index')
|
||||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||||
@ -14,11 +15,15 @@ class Audiobook {
|
|||||||
this.id = null
|
this.id = null
|
||||||
this.ino = null // Inode
|
this.ino = null // Inode
|
||||||
|
|
||||||
|
this.libraryId = null
|
||||||
|
this.folderId = null
|
||||||
|
|
||||||
this.path = null
|
this.path = null
|
||||||
this.fullPath = null
|
this.fullPath = null
|
||||||
|
|
||||||
this.addedAt = null
|
this.addedAt = null
|
||||||
this.lastUpdate = null
|
this.lastUpdate = null
|
||||||
|
this.lastScan = null
|
||||||
|
this.scanVersion = null
|
||||||
|
|
||||||
this.tracks = []
|
this.tracks = []
|
||||||
this.missingParts = []
|
this.missingParts = []
|
||||||
@ -41,11 +46,14 @@ class Audiobook {
|
|||||||
construct(audiobook) {
|
construct(audiobook) {
|
||||||
this.id = audiobook.id
|
this.id = audiobook.id
|
||||||
this.ino = audiobook.ino || null
|
this.ino = audiobook.ino || null
|
||||||
|
this.libraryId = audiobook.libraryId || 'main'
|
||||||
|
this.folderId = audiobook.folderId || 'audiobooks'
|
||||||
this.path = audiobook.path
|
this.path = audiobook.path
|
||||||
this.fullPath = audiobook.fullPath
|
this.fullPath = audiobook.fullPath
|
||||||
this.addedAt = audiobook.addedAt
|
this.addedAt = audiobook.addedAt
|
||||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||||
|
this.lastScan = audiobook.lastScan || null
|
||||||
|
this.scanVersion = audiobook.scanVersion || null
|
||||||
|
|
||||||
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
||||||
this.missingParts = audiobook.missingParts
|
this.missingParts = audiobook.missingParts
|
||||||
@ -127,10 +135,6 @@ class Audiobook {
|
|||||||
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasDescriptionTextFile() {
|
|
||||||
return !!this._otherFiles.find(of => of.filename === 'desc.txt')
|
|
||||||
}
|
|
||||||
|
|
||||||
bookToJSON() {
|
bookToJSON() {
|
||||||
return this.book ? this.book.toJSON() : null
|
return this.book ? this.book.toJSON() : null
|
||||||
}
|
}
|
||||||
@ -144,6 +148,8 @@ class Audiobook {
|
|||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
folderId: this.folderId,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
author: this.author,
|
author: this.author,
|
||||||
cover: this.cover,
|
cover: this.cover,
|
||||||
@ -151,6 +157,8 @@ class Audiobook {
|
|||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
lastUpdate: this.lastUpdate,
|
lastUpdate: this.lastUpdate,
|
||||||
|
lastScan: this.lastScan,
|
||||||
|
scanVersion: this.scanVersion,
|
||||||
missingParts: this.missingParts,
|
missingParts: this.missingParts,
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
@ -166,6 +174,8 @@ class Audiobook {
|
|||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
ino: this.ino,
|
ino: this.ino,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
folderId: this.folderId,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
@ -188,6 +198,9 @@ class Audiobook {
|
|||||||
toJSONExpanded() {
|
toJSONExpanded() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
ino: this.ino,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
folderId: this.folderId,
|
||||||
path: this.path,
|
path: this.path,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
addedAt: this.addedAt,
|
addedAt: this.addedAt,
|
||||||
@ -284,13 +297,10 @@ class Audiobook {
|
|||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scans in v1.3.0 or lower will need to rescan audiofiles to pickup metadata and embedded cover
|
|
||||||
checkNeedsAudioFileRescan() {
|
|
||||||
return !!(this.audioFiles || []).find(af => af.isOldAudioFile || af.codec === null)
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(data) {
|
setData(data) {
|
||||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
|
this.libraryId = data.libraryId || 'main'
|
||||||
|
this.folderId = data.folderId || 'audiobooks'
|
||||||
this.ino = data.ino || null
|
this.ino = data.ino || null
|
||||||
|
|
||||||
this.path = data.path
|
this.path = data.path
|
||||||
@ -307,7 +317,26 @@ class Audiobook {
|
|||||||
this.setBook(data)
|
this.setBook(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkHasOldCoverPath() {
|
||||||
|
return this.book.cover && !this.book.coverFullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastScan(version) {
|
||||||
|
this.lastScan = Date.now()
|
||||||
|
this.lastUpdate = Date.now()
|
||||||
|
this.scanVersion = version
|
||||||
|
}
|
||||||
|
|
||||||
setBook(data) {
|
setBook(data) {
|
||||||
|
// Use first image file as cover
|
||||||
|
if (this.otherFiles && this.otherFiles.length) {
|
||||||
|
var imageFile = this.otherFiles.find(f => f.filetype === 'image')
|
||||||
|
if (imageFile) {
|
||||||
|
data.coverFullPath = imageFile.fullPath
|
||||||
|
data.cover = Path.normalize(Path.join(`/s/book/${this.id}`, imageFile.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.book = new Book()
|
this.book = new Book()
|
||||||
this.book.setData(data)
|
this.book.setData(data)
|
||||||
}
|
}
|
||||||
@ -432,12 +461,13 @@ class Audiobook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On scan check other files found with other files saved
|
// On scan check other files found with other files saved
|
||||||
async syncOtherFiles(newOtherFiles, forceRescan = false) {
|
async syncOtherFiles(newOtherFiles, metadataPath, forceRescan = false) {
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
|
|
||||||
var currOtherFileNum = this.otherFiles.length
|
var currOtherFileNum = this.otherFiles.length
|
||||||
|
|
||||||
var alreadyHadDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
var alreadyHasDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
||||||
|
var alreadyHasReaderTxt = this.otherFiles.find(of => of.filename === 'reader.txt')
|
||||||
|
|
||||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||||
@ -448,9 +478,9 @@ class Audiobook {
|
|||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If desc.txt is new or forcing rescan then read it and update description if empty
|
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
|
||||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
var descriptionTxt = this.otherFiles.find(file => file.filename === 'desc.txt')
|
||||||
if (descriptionTxt && (!alreadyHadDescTxt || forceRescan)) {
|
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
|
||||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||||
if (newDescription) {
|
if (newDescription) {
|
||||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||||
@ -458,10 +488,19 @@ class Audiobook {
|
|||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If reader.txt is new or forcing rescan then read it and update narrarator (will overwrite)
|
||||||
|
var readerTxt = this.otherFiles.find(file => file.filename === 'reader.txt')
|
||||||
|
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
||||||
|
var newReader = await readTextFile(readerTxt.fullPath)
|
||||||
|
if (newReader) {
|
||||||
|
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
||||||
|
this.update({ book: { narrarator: newReader } })
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Should use inode
|
|
||||||
newOtherFiles.forEach((file) => {
|
newOtherFiles.forEach((file) => {
|
||||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino)
|
||||||
if (!existingOtherFile) {
|
if (!existingOtherFile) {
|
||||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
||||||
this.addOtherFile(file)
|
this.addOtherFile(file)
|
||||||
@ -469,21 +508,76 @@ class Audiobook {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if cover was a local image and that it still exists
|
|
||||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||||
|
|
||||||
|
// OLD Path Check if cover was a local image and that it still exists
|
||||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, this.book.cover.substr('/local/'.length)))
|
var coverStripped = this.book.cover.substr('/local/'.length)
|
||||||
|
// Check if was removed first
|
||||||
|
var coverStillExists = imageFiles.find(f => comparePaths(f.path, coverStripped))
|
||||||
if (!coverStillExists) {
|
if (!coverStillExists) {
|
||||||
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
||||||
this.book.cover = null
|
this.book.removeCover()
|
||||||
|
} else {
|
||||||
|
var oldFormat = this.book.cover
|
||||||
|
|
||||||
|
// Update book cover path to new format
|
||||||
|
this.book.fullCoverPath = Path.join(this.fullPath, this.book.cover.substr(7))
|
||||||
|
this.book.cover = Path.normalize(coverStripped.replace(this.path, `/s/book/${this.id}`))
|
||||||
|
Logger.debug(`[Audiobook] updated book cover to new format "${oldFormat}" => "${this.book.cover}"`)
|
||||||
|
}
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if book was removed from book dir
|
||||||
|
if (this.book.cover && this.book.cover.substr(1).startsWith('s/book/')) {
|
||||||
|
// Fixing old cover paths
|
||||||
|
if (!this.book.coverFullPath) {
|
||||||
|
this.book.coverFullPath = Path.join(this.fullPath, this.book.cover.substr(`/s/book/${this.id}`.length))
|
||||||
|
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||||
|
if (!coverStillExists) {
|
||||||
|
Logger.info(`[Audiobook] Local cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||||
|
this.book.removeCover()
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.book.cover && this.book.cover.substr(1).startsWith('metadata')) {
|
||||||
|
// Fixing old cover paths
|
||||||
|
if (!this.book.coverFullPath) {
|
||||||
|
this.book.coverFullPath = Path.join(metadataPath, this.book.cover.substr('/metadata/'.length))
|
||||||
|
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||||
|
if (!coverStillExists) {
|
||||||
|
Logger.info(`[Audiobook] Metadata cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||||
|
this.book.removeCover()
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.book.cover && !this.book.coverFullPath) {
|
||||||
|
if (this.book.cover.startsWith('http')) {
|
||||||
|
Logger.debug(`[Audiobook] Still using http path for cover "${this.book.cover}" - should update to local`)
|
||||||
|
this.book.coverFullPath = this.book.cover
|
||||||
|
hasUpdates = true
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[Audiobook] Full cover path still not set "${this.book.cover}"`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no cover set and image file exists then use it
|
// If no cover set and image file exists then use it
|
||||||
if (!this.book.cover && imageFiles.length) {
|
if (!this.book.cover && imageFiles.length) {
|
||||||
this.book.cover = Path.join('/local', imageFiles[0].path)
|
var imagePathRelativeToBook = imageFiles[0].path.replace(this.path, '')
|
||||||
Logger.info(`[Audiobook] Local cover was set | "${this.title}"`)
|
this.book.cover = Path.join(`/s/book/${this.id}`, imagePathRelativeToBook)
|
||||||
|
this.book.coverFullPath = imageFiles[0].fullPath
|
||||||
|
Logger.info(`[Audiobook] Local cover was set to "${this.book.cover}" | "${this.title}"`)
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
@ -582,6 +676,12 @@ class Audiobook {
|
|||||||
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||||
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
||||||
|
|
||||||
|
var coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||||
|
if (coverAlreadyExists) {
|
||||||
|
Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${this.title}" - bail`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
||||||
if (success) {
|
if (success) {
|
||||||
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
||||||
@ -591,16 +691,32 @@ class Audiobook {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If desc.txt exists then use it as description
|
// Look for desc.txt and reader.txt and update details if found
|
||||||
async saveDescriptionFromTextFile() {
|
async saveDataFromTextFiles() {
|
||||||
var descriptionTextFile = this.otherFiles.find(file => file.filename === 'desc.txt')
|
var bookUpdatePayload = {}
|
||||||
if (!descriptionTextFile) return false
|
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
||||||
var newDescription = await readTextFile(descriptionTextFile.fullPath)
|
if (descriptionText) {
|
||||||
if (!newDescription) return false
|
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||||
return this.update({ book: { description: newDescription } })
|
bookUpdatePayload.description = descriptionText
|
||||||
|
}
|
||||||
|
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
||||||
|
if (readerText) {
|
||||||
|
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrarator with "${readerText}"`)
|
||||||
|
bookUpdatePayload.narrarator = readerText
|
||||||
|
}
|
||||||
|
if (Object.keys(bookUpdatePayload).length) {
|
||||||
|
return this.update({ book: bookUpdatePayload })
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio file metadata tags map to EMPTY book details
|
fetchTextFromTextFile(textfileName) {
|
||||||
|
var textFile = this.otherFiles.find(file => file.filename === textfileName)
|
||||||
|
if (!textFile) return false
|
||||||
|
return readTextFile(textFile.fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio file metadata tags map to book details (will not overwrite)
|
||||||
setDetailsFromFileMetadata() {
|
setDetailsFromFileMetadata() {
|
||||||
if (!this.audioFiles.length) return false
|
if (!this.audioFiles.length) return false
|
||||||
var audioFile = this.audioFiles[0]
|
var audioFile = this.audioFiles[0]
|
||||||
|
@ -18,6 +18,7 @@ class Book {
|
|||||||
this.publisher = null
|
this.publisher = null
|
||||||
this.description = null
|
this.description = null
|
||||||
this.cover = null
|
this.cover = null
|
||||||
|
this.coverFullPath = null
|
||||||
this.genres = []
|
this.genres = []
|
||||||
this.lastUpdate = null
|
this.lastUpdate = null
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ class Book {
|
|||||||
this.publisher = book.publisher
|
this.publisher = book.publisher
|
||||||
this.description = book.description
|
this.description = book.description
|
||||||
this.cover = book.cover
|
this.cover = book.cover
|
||||||
|
this.coverFullPath = book.coverFullPath || null
|
||||||
this.genres = book.genres
|
this.genres = book.genres
|
||||||
this.lastUpdate = book.lastUpdate || Date.now()
|
this.lastUpdate = book.lastUpdate || Date.now()
|
||||||
}
|
}
|
||||||
@ -65,6 +67,7 @@ class Book {
|
|||||||
publisher: this.publisher,
|
publisher: this.publisher,
|
||||||
description: this.description,
|
description: this.description,
|
||||||
cover: this.cover,
|
cover: this.cover,
|
||||||
|
coverFullPath: this.coverFullPath,
|
||||||
genres: this.genres,
|
genres: this.genres,
|
||||||
lastUpdate: this.lastUpdate
|
lastUpdate: this.lastUpdate
|
||||||
}
|
}
|
||||||
@ -100,20 +103,13 @@ class Book {
|
|||||||
this.publishYear = data.publishYear || null
|
this.publishYear = data.publishYear || null
|
||||||
this.description = data.description || null
|
this.description = data.description || null
|
||||||
this.cover = data.cover || null
|
this.cover = data.cover || null
|
||||||
|
this.coverFullPath = data.coverFullPath || null
|
||||||
this.genres = data.genres || []
|
this.genres = data.genres || []
|
||||||
this.lastUpdate = Date.now()
|
this.lastUpdate = Date.now()
|
||||||
|
|
||||||
if (data.author) {
|
if (data.author) {
|
||||||
this.setParseAuthor(this.author)
|
this.setParseAuthor(this.author)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use first image file as cover
|
|
||||||
if (data.otherFiles && data.otherFiles.length) {
|
|
||||||
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
|
|
||||||
if (imageFile) {
|
|
||||||
this.cover = Path.normalize(Path.join('/local', imageFile.path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(payload) {
|
update(payload) {
|
||||||
@ -168,6 +164,12 @@ class Book {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeCover() {
|
||||||
|
this.cover = null
|
||||||
|
this.coverFullPath = null
|
||||||
|
this.lastUpdate = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||||
syncPathsUpdated(audiobookData) {
|
syncPathsUpdated(audiobookData) {
|
||||||
|
36
server/objects/Folder.js
Normal file
36
server/objects/Folder.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
class Folder {
|
||||||
|
constructor(folder = null) {
|
||||||
|
this.id = null
|
||||||
|
this.fullPath = null
|
||||||
|
this.libraryId = null
|
||||||
|
this.addedAt = null
|
||||||
|
|
||||||
|
if (folder) {
|
||||||
|
this.construct(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(folder) {
|
||||||
|
this.id = folder.id
|
||||||
|
this.fullPath = folder.fullPath
|
||||||
|
this.libraryId = folder.libraryId
|
||||||
|
this.addedAt = folder.addedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
fullPath: this.fullPath,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
addedAt: this.addedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
this.id = data.id ? data.id : 'fol' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
|
this.fullPath = data.fullPath
|
||||||
|
this.libraryId = data.libraryId
|
||||||
|
this.addedAt = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Folder
|
95
server/objects/Library.js
Normal file
95
server/objects/Library.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const Folder = require('./Folder')
|
||||||
|
|
||||||
|
class Library {
|
||||||
|
constructor(library = null) {
|
||||||
|
this.id = null
|
||||||
|
this.name = null
|
||||||
|
this.folders = []
|
||||||
|
|
||||||
|
this.createdAt = null
|
||||||
|
this.lastUpdate = null
|
||||||
|
|
||||||
|
if (library) {
|
||||||
|
this.construct(library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get folderPaths() {
|
||||||
|
return this.folders.map(f => f.fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
construct(library) {
|
||||||
|
this.id = library.id
|
||||||
|
this.name = library.name
|
||||||
|
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||||
|
this.createdAt = library.createdAt
|
||||||
|
this.lastUpdate = library.lastUpdate
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
folders: (this.folders || []).map(f => f.toJSON()),
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
lastUpdate: this.lastUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(data) {
|
||||||
|
this.id = data.id ? data.id : 'lib' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||||
|
this.name = data.name
|
||||||
|
if (data.folder) {
|
||||||
|
this.folders = [
|
||||||
|
new Folder(data.folder)
|
||||||
|
]
|
||||||
|
} else if (data.folders) {
|
||||||
|
this.folders = data.folders.map(folder => {
|
||||||
|
var newFolder = new Folder()
|
||||||
|
newFolder.setData({
|
||||||
|
fullPath: folder.fullPath,
|
||||||
|
libraryId: this.id
|
||||||
|
})
|
||||||
|
return newFolder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.createdAt = Date.now()
|
||||||
|
this.lastUpdate = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(payload) {
|
||||||
|
var hasUpdates = false
|
||||||
|
if (payload.name && payload.name !== this.name) {
|
||||||
|
this.name = payload.name
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
if (payload.folders) {
|
||||||
|
var newFolders = payload.folders.filter(f => !f.id)
|
||||||
|
var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id))
|
||||||
|
|
||||||
|
if (removedFolders.length) {
|
||||||
|
var removedFolderIds = removedFolders.map(f => f.id)
|
||||||
|
this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFolders.length) {
|
||||||
|
newFolders.forEach((folderData) => {
|
||||||
|
var newFolder = new Folder()
|
||||||
|
newFolder.setData(folderData)
|
||||||
|
this.folders.push(newFolder)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUpdates = newFolders.length || removedFolders.length
|
||||||
|
}
|
||||||
|
if (hasUpdates) {
|
||||||
|
this.lastUpdate = Date.now()
|
||||||
|
}
|
||||||
|
return hasUpdates
|
||||||
|
}
|
||||||
|
|
||||||
|
checkFullPathInLibrary(fullPath) {
|
||||||
|
return this.folders.find(folder => fullPath.startsWith(folder.fullPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = Library
|
@ -8,6 +8,7 @@ class ServerSettings {
|
|||||||
this.autoTagNew = false
|
this.autoTagNew = false
|
||||||
this.newTagExpireDays = 15
|
this.newTagExpireDays = 15
|
||||||
this.scannerParseSubtitle = false
|
this.scannerParseSubtitle = false
|
||||||
|
this.scannerFindCovers = false
|
||||||
this.coverDestination = CoverDestination.METADATA
|
this.coverDestination = CoverDestination.METADATA
|
||||||
this.saveMetadataFile = false
|
this.saveMetadataFile = false
|
||||||
this.rateLimitLoginRequests = 10
|
this.rateLimitLoginRequests = 10
|
||||||
@ -22,6 +23,7 @@ class ServerSettings {
|
|||||||
construct(settings) {
|
construct(settings) {
|
||||||
this.autoTagNew = settings.autoTagNew
|
this.autoTagNew = settings.autoTagNew
|
||||||
this.newTagExpireDays = settings.newTagExpireDays
|
this.newTagExpireDays = settings.newTagExpireDays
|
||||||
|
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||||
@ -39,6 +41,7 @@ class ServerSettings {
|
|||||||
id: this.id,
|
id: this.id,
|
||||||
autoTagNew: this.autoTagNew,
|
autoTagNew: this.autoTagNew,
|
||||||
newTagExpireDays: this.newTagExpireDays,
|
newTagExpireDays: this.newTagExpireDays,
|
||||||
|
scannerFindCovers: this.scannerFindCovers,
|
||||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||||
coverDestination: this.coverDestination,
|
coverDestination: this.coverDestination,
|
||||||
saveMetadataFile: !!this.saveMetadataFile,
|
saveMetadataFile: !!this.saveMetadataFile,
|
||||||
|
@ -192,7 +192,6 @@ module.exports.scanAudioFiles = scanAudioFiles
|
|||||||
|
|
||||||
|
|
||||||
async function rescanAudioFiles(audiobook) {
|
async function rescanAudioFiles(audiobook) {
|
||||||
|
|
||||||
var audioFiles = audiobook.audioFiles
|
var audioFiles = audiobook.audioFiles
|
||||||
var updates = 0
|
var updates = 0
|
||||||
|
|
||||||
@ -215,7 +214,7 @@ async function rescanAudioFiles(audiobook) {
|
|||||||
// Fallback to checking path
|
// Fallback to checking path
|
||||||
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
|
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
|
||||||
if (matchingAudioTrack) {
|
if (matchingAudioTrack) {
|
||||||
Logger.warn(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
Logger.error(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
||||||
matchingAudioTrack.ino = audioFile.ino
|
matchingAudioTrack.ino = audioFile.ino
|
||||||
matchingAudioTrack.syncMetadata(audioFile)
|
matchingAudioTrack.syncMetadata(audioFile)
|
||||||
} else {
|
} else {
|
||||||
|
@ -23,6 +23,8 @@ function isAudioFile(path) {
|
|||||||
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Input: array of relative file paths
|
||||||
|
// Output: map of files grouped into potential audiobook dirs
|
||||||
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||||
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
||||||
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
||||||
@ -110,25 +112,26 @@ function getFileType(ext) {
|
|||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary scan: abRootPath is /audiobooks
|
// Scan folder
|
||||||
async function scanRootDir(abRootPath, serverSettings = {}) {
|
async function scanRootDir(folder, serverSettings = {}) {
|
||||||
|
var folderPath = folder.fullPath
|
||||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||||
|
|
||||||
var pathdata = await getPaths(abRootPath)
|
var pathdata = await getPaths(folderPath)
|
||||||
var filepaths = pathdata.files.map(filepath => {
|
var filepaths = pathdata.files.map(filepath => {
|
||||||
return Path.normalize(filepath).replace(abRootPath, '')
|
return Path.normalize(filepath).replace(folderPath, '')
|
||||||
})
|
})
|
||||||
|
|
||||||
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
|
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
|
||||||
|
|
||||||
if (!Object.keys(audiobookGrouping).length) {
|
if (!Object.keys(audiobookGrouping).length) {
|
||||||
Logger.error('Root path has no audiobooks')
|
Logger.error('Root path has no audiobooks', filepaths)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
var audiobooks = []
|
var audiobooks = []
|
||||||
for (const audiobookPath in audiobookGrouping) {
|
for (const audiobookPath in audiobookGrouping) {
|
||||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
|
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||||
|
|
||||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||||
for (let i = 0; i < fileObjs.length; i++) {
|
for (let i = 0; i < fileObjs.length; i++) {
|
||||||
@ -136,6 +139,8 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
|||||||
}
|
}
|
||||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||||
audiobooks.push({
|
audiobooks.push({
|
||||||
|
folderId: folder.id,
|
||||||
|
libraryId: folder.libraryId,
|
||||||
ino: audiobookIno,
|
ino: audiobookIno,
|
||||||
...audiobookData,
|
...audiobookData,
|
||||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||||
@ -147,7 +152,7 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
|||||||
module.exports.scanRootDir = scanRootDir
|
module.exports.scanRootDir = scanRootDir
|
||||||
|
|
||||||
// Input relative filepath, output all details that can be parsed
|
// Input relative filepath, output all details that can be parsed
|
||||||
function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
|
||||||
var splitDir = dir.split(Path.sep)
|
var splitDir = dir.split(Path.sep)
|
||||||
|
|
||||||
// Audio files will always be in the directory named for the title
|
// Audio files will always be in the directory named for the title
|
||||||
@ -218,11 +223,11 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
|||||||
volumeNumber,
|
volumeNumber,
|
||||||
publishYear,
|
publishYear,
|
||||||
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||||
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
fullPath: Path.join(folderPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) {
|
async function getAudiobookFileData(folder, audiobookPath, serverSettings = {}) {
|
||||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||||
|
|
||||||
var paths = await getPaths(audiobookPath)
|
var paths = await getPaths(audiobookPath)
|
||||||
@ -235,9 +240,11 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
|||||||
return pathsA - pathsB
|
return pathsA - pathsB
|
||||||
})
|
})
|
||||||
|
|
||||||
var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1)
|
var audiobookDir = Path.normalize(audiobookPath).replace(folder.fullPath, '').slice(1)
|
||||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle)
|
var audiobookData = getAudiobookDataFromDir(folder.fullPath, audiobookDir, parseSubtitle)
|
||||||
var audiobook = {
|
var audiobook = {
|
||||||
|
folderId: folder.id,
|
||||||
|
libraryId: folder.libraryId,
|
||||||
...audiobookData,
|
...audiobookData,
|
||||||
audioFiles: [],
|
audioFiles: [],
|
||||||
otherFiles: []
|
otherFiles: []
|
||||||
@ -246,7 +253,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
|||||||
for (let i = 0; i < filepaths.length; i++) {
|
for (let i = 0; i < filepaths.length; i++) {
|
||||||
var filepath = filepaths[i]
|
var filepath = filepaths[i]
|
||||||
|
|
||||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
var relpath = Path.normalize(filepath).replace(folder.fullPath, '').slice(1)
|
||||||
var extname = Path.extname(filepath)
|
var extname = Path.extname(filepath)
|
||||||
var basename = Path.basename(filepath)
|
var basename = Path.basename(filepath)
|
||||||
var ino = await getIno(filepath)
|
var ino = await getIno(filepath)
|
||||||
|
Loading…
Reference in New Issue
Block a user