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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,45 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
library: {
type: Object,
default: () => {}
}
},
data() {
return {
processing: false
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.library ? 'Update Library' : 'New Library'
}
},
methods: {},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@ -0,0 +1,99 @@
<template>
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
<div class="flex items-center mb-4">
<p>{{ libraries.length }} Libraries</p>
<!-- <div class="flex-grow" />
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
</div>
<template v-for="library in libraries">
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
</template>
</div>
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
</div>
</modals-modal>
</template>
<script>
export default {
data() {
return {
selectedLibrary: null,
processing: false,
showAddLibrary: false
}
},
computed: {
show: {
get() {
return this.$store.state.libraries.showModal
},
set(val) {
this.$store.commit('libraries/setShowModal', val)
}
},
title() {
return 'Libraries'
},
currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary']
},
currentLibraryId() {
return this.currentLibrary ? this.currentLibrary.id : null
},
libraries() {
return this.$store.state.libraries.libraries
}
},
watch: {
show(newVal) {
if (newVal) this.showAddLibrary = false
}
},
methods: {
async clickLibrary(library) {
await this.$store.dispatch('libraries/fetch', library.id)
this.$router.push(`/library/${library.id}`)
this.show = false
},
editLibrary(library) {
this.selectedLibrary = library
this.showAddLibrary = true
},
addLibraryClick() {
this.selectedLibrary = null
this.showAddLibrary = true
},
deleteLibrary(library) {
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
console.log('Delete library', library)
this.processing = true
this.$axios
.$delete(`/api/library/${library.id}`)
.then(() => {
console.log('Library delete success')
this.$toast.success(`Library "${library.name}" deleted`)
this.processing = false
})
.catch((error) => {
console.error('Failed to delete library', error)
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(errMsg)
this.processing = false
})
}
}
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

@ -4,9 +4,10 @@ const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject } = require('./utils/index')
const Library = require('./objects/Library')
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.scanner = scanner
this.auth = auth
@ -14,6 +15,7 @@ class ApiController {
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.coverController = coverController
this.watcher = watcher
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
@ -26,7 +28,14 @@ class ApiController {
this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.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.post('/audiobooks/delete', this.batchDeleteAudiobooks.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.get('/download/:id', this.download.bind(this))
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
}
find(req, res) {
@ -77,6 +88,102 @@ class ApiController {
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) {
var audiobooks = []
if (req.query.q) {
@ -370,7 +477,7 @@ class ApiController {
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertUser(newUser)
var success = await this.db.insertEntity('user', newUser)
if (success) {
this.clientEmitter(req.user.id, 'user_added', newUser)
res.json({
@ -492,5 +599,49 @@ class ApiController {
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

View File

@ -20,7 +20,7 @@ class CoverController {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
return {
fullPath: audiobook.fullPath,
relPath: Path.join('/local', audiobook.path)
relPath: '/s/book/' + audiobook.id
}
} else {
return {

View File

@ -4,20 +4,25 @@ const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User')
const Library = require('./objects/Library')
const ServerSettings = require('./objects/ServerSettings')
class Db {
constructor(CONFIG_PATH) {
this.ConfigPath = CONFIG_PATH
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
this.UsersPath = Path.join(CONFIG_PATH, 'users')
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
constructor(ConfigPath, AudiobookPath) {
this.ConfigPath = ConfigPath
this.AudiobookPath = AudiobookPath
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
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.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.users = []
this.libraries = []
this.audiobooks = []
this.settings = []
@ -27,18 +32,14 @@ class Db {
getEntityDb(entityName) {
if (entityName === 'user') return this.usersDb
else if (entityName === 'audiobook') return this.audiobooksDb
else if (entityName === 'library') return this.librariesDb
return this.settingsDb
}
getEntityDbKey(entityName) {
if (entityName === 'user') return 'usersDb'
else if (entityName === 'audiobook') return 'audiobooksDb'
return 'settingsDb'
}
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
else if (entityName === 'audiobook') return 'audiobooks'
else if (entityName === 'library') return 'libraries'
return 'settings'
}
@ -46,7 +47,6 @@ class Db {
return new User({
id: 'root',
type: 'root',
username: 'root',
pash: '',
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() {
await this.load()
@ -63,25 +77,33 @@ class Db {
if (!this.users.find(u => u.type === 'root')) {
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
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) {
this.serverSettings = new ServerSettings()
await this.insertSettings(this.serverSettings)
await this.insertEntity('settings', this.serverSettings)
}
}
async load() {
var p1 = this.audiobooksDb.select(() => true).then((results) => {
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) => {
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) {
this.settings = results.data
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) {
return this.settingsDb.insert([settings]).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
this.settings = this.settings.concat(settings)
}).catch((error) => {
Logger.error(`[DB] Insert settings Failed ${error}`)
})
}
// insertAudiobook(audiobook) {
// return this.insertAudiobooks([audiobook])
// }
insertAudiobook(audiobook) {
return this.insertAudiobooks([audiobook])
}
insertAudiobooks(audiobooks) {
return this.audiobooksDb.insert(audiobooks).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
this.audiobooks = this.audiobooks.concat(audiobooks)
}).catch((error) => {
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
})
}
// insertAudiobooks(audiobooks) {
// return this.audiobooksDb.insert(audiobooks).then((results) => {
// Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
// this.audiobooks = this.audiobooks.concat(audiobooks)
// }).catch((error) => {
// Logger.error(`[DB] Insert audiobooks Failed ${error}`)
// })
// }
updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
@ -125,16 +138,25 @@ class Db {
})
}
insertUser(user) {
return this.usersDb.insert([user]).then((results) => {
Logger.debug(`[DB] Inserted user ${results.inserted}`)
this.users.push(user)
return true
}).catch((error) => {
Logger.error(`[DB] Insert user Failed ${error}`)
return false
})
}
// insertUser(user) {
// return this.usersDb.insert([user]).then((results) => {
// Logger.debug(`[DB] Inserted user ${results.inserted}`)
// this.users.push(user)
// return true
// }).catch((error) => {
// Logger.error(`[DB] Insert user Failed ${error}`)
// return false
// })
// }
// 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) {
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) {
var entityDb = this.getEntityDb(entityName)
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {

View File

@ -1,14 +1,19 @@
const fs = require('fs-extra')
const Path = require('path')
// Utils
const Logger = require('./Logger')
const BookFinder = require('./BookFinder')
const Audiobook = require('./objects/Audiobook')
const { version } = require('../package.json')
const audioFileScanner = require('./utils/audioFileScanner')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult, CoverDestination } = require('./utils/constants')
// Classes
const BookFinder = require('./BookFinder')
const Audiobook = require('./objects/Audiobook')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH
@ -20,6 +25,8 @@ class Scanner {
this.emitter = emitter
this.cancelScan = false
this.cancelLibraryScan = {}
this.librariesScanning = []
this.bookFinder = new BookFinder()
}
@ -32,7 +39,7 @@ class Scanner {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
return {
fullPath: audiobook.fullPath,
relPath: Path.join('/local', audiobook.path)
relPath: '/s/book/' + audiobook.id
}
} else {
return {
@ -97,164 +104,151 @@ class Scanner {
return filesUpdated
}
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
var existingAudiobook = this.audiobooks.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 = this.audiobooks.find(a => a.path === audiobookData.path)
if (existingAudiobook) {
existingAudiobook.ino = audiobookData.ino
hasUpdatedIno = true
}
async scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, forceAudioFileScan) {
// Always sync files and inode values
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
if (hasUpdatedIno || filesInodeUpdated > 0) {
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
hasUpdatedIno = true
}
if (existingAudiobook) {
// Always sync files and inode values
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
if (hasUpdatedIno || filesInodeUpdated > 0) {
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
hasUpdatedIno = true
}
// TEMP: Check if is older audiobook and needs force rescan
if (!forceAudioFileScan && (!existingAudiobook.scanVersion || existingAudiobook.checkHasOldCoverPath())) {
Logger.info(`[Scanner] Force rescan for "${existingAudiobook.title}" | Last scan v${existingAudiobook.scanVersion} | Old Cover Path ${!!existingAudiobook.checkHasOldCoverPath()}`)
forceAudioFileScan = 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
if (!forceAudioFileScan && existingAudiobook.checkNeedsAudioFileRescan()) {
Logger.info(`[Scanner] Re-Scanning all audio files for "${existingAudiobook.title}" (last scan <= 1.3.0)`)
forceAudioFileScan = true
}
// REMOVE: No valid audio files
// 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)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
// REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
return ScanResult.REMOVED
}
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
// Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
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
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
// Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
// Check for 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}"`)
// 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 path (path may have been renamed)
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
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 {
// New audio file, triple check for matching file path
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 {
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
if (newAudioFiles.length) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
// 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
// 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) {
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
return ScanResult.NOTHING
@ -262,15 +256,16 @@ class Scanner {
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
// Scan audio files and set tracks, pulls metadata
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
return ScanResult.NOTHING
}
if (audiobook.hasDescriptionTextFile) {
await audiobook.saveDescriptionFromTextFile()
}
// Look for desc.txt and reader.txt and update
await audiobook.saveDataFromTextFiles()
if (audiobook.hasEmbeddedCoverArt) {
var outputCoverDirs = this.getCoverDirectory(audiobook)
@ -280,22 +275,79 @@ class Scanner {
}
}
// Set book details from metadata pulled from audio files
audiobook.setDetailsFromFileMetadata()
// Check for gaps in track numbers
audiobook.checkUpdateMissingParts()
// Set chapters from audio files
audiobook.setChapters()
audiobook.setLastScan(version)
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())
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"
// TEMP - update ino for each audiobook
if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[i]
if (audiobooksInLibrary.length) {
for (let i = 0; i < audiobooksInLibrary.length; i++) {
var ab = audiobooksInLibrary[i]
// Update ino if inos are not set
var shouldUpdateIno = ab.hasMissingIno
if (shouldUpdateIno) {
@ -309,13 +361,23 @@ class Scanner {
}
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
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
if (this.cancelScan) {
this.cancelScan = false
if (this.cancelLibraryScan[libraryId]) {
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
}
@ -327,8 +389,8 @@ class Scanner {
}
// Check for removed audiobooks
for (let i = 0; i < this.audiobooks.length; i++) {
var audiobook = this.audiobooks[i]
for (let i = 0; i < audiobooksInLibrary.length; i++) {
var audiobook = audiobooksInLibrary[i]
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
if (!dataFound) {
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
@ -338,9 +400,13 @@ class Scanner {
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
}
if (this.cancelScan) {
this.cancelScan = false
return null
if (this.cancelLibraryScan[libraryId]) {
console.log('1', 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: scanResults })
return
}
}
@ -353,21 +419,26 @@ class Scanner {
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
scanType: 'files',
id: libraryId,
name: library.name,
scanType: 'library',
progress: {
total: audiobookDataFound.length,
done: i + 1,
progress
}
})
if (this.cancelScan) {
this.cancelScan = false
if (this.cancelLibraryScan[libraryId]) {
console.log(this.cancelLibraryScan)
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
delete this.cancelLibraryScan[libraryId]
break
}
}
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)}`)
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) {
@ -376,78 +447,173 @@ class Scanner {
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
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}"`)
return this.scanAudiobook(audiobook.fullPath, true)
return this.scanAudiobook(folder, audiobook.fullPath, true)
}
async scanAudiobook(audiobookPath, forceAudioFileScan = false) {
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
async scanAudiobook(folder, audiobookFullPath, forceAudioFileScan = false) {
Logger.debug('[Scanner] scanAudiobook', audiobookFullPath)
var audiobookData = await getAudiobookFileData(folder, audiobookFullPath, this.db.serverSettings)
if (!audiobookData) {
return ScanResult.NOTHING
}
audiobookData.ino = await getIno(audiobookData.fullPath)
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
}
// Files were modified in this directory, check it out
async checkDir(dir) {
var exists = await fs.pathExists(dir)
if (!exists) {
// Audiobook was deleted, TODO: Should confirm this better
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
if (audiobook) {
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobookJSON)
return ScanResult.REMOVED
// async checkDir(dir) {
// var exists = await fs.pathExists(dir)
// if (!exists) {
// // Audiobook was deleted, TODO: Should confirm this better
// var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
// if (audiobook) {
// var audiobookJSON = audiobook.toJSONMinified()
// await this.db.removeEntity('audiobook', audiobook.id)
// this.emitter('audiobook_removed', audiobookJSON)
// return ScanResult.REMOVED
// }
// // Path inside audiobook was deleted, scan audiobook
// audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
// if (audiobook) {
// Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
// return this.scanAudiobook(audiobook.fullPath)
// }
// Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
// return ScanResult.NOTHING
// }
// // Check if this is a subdirectory of an audiobook
// var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
// if (audiobook) {
// Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
// return this.scanAudiobook(audiobook.fullPath)
// }
// // Check if an audiobook is a subdirectory of this dir
// audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
// if (audiobook) {
// Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
// return ScanResult.NOTHING
// }
// // Must be a new audiobook
// Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
// return this.scanAudiobook(dir)
// }
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
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)
// Check if an audiobook is a subdirectory of this dir
var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath))
if (childAudiobook) {
Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`)
bookGroupingResults[bookDir] = ScanResult.NOTHING
continue;
}
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
return ScanResult.NOTHING
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, fullPath)
}
// 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)
return bookGroupingResults
}
// Array of files that may have been renamed, removed or added
async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
// Array of file update objects that may have been renamed, removed or added
async filesChanged(fileUpdates) {
if (!fileUpdates.length) return null
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)
// Group files by folder
var folderGroups = {}
fileUpdates.forEach((file) => {
if (folderGroups[file.folderId]) {
folderGroups[file.folderId].fileUpdates.push(file)
} else {
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() {
@ -495,7 +661,8 @@ class Scanner {
}
return {
found,
notFound
notFound,
failed
}
}

View File

@ -6,8 +6,13 @@ const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
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 Watcher = require('./Watcher')
const Scanner = require('./Scanner')
@ -18,7 +23,7 @@ const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const CoverController = require('./CoverController')
const Logger = require('./Logger')
class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
@ -32,7 +37,7 @@ class Server {
fs.ensureDirSync(METADATA_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.watcher = new Watcher(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.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.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.expressApp = null
this.server = null
this.io = null
this.clients = {}
this.isScanning = false
this.isScanningCovers = false
this.isInitialized = false
}
get audiobooks() {
return this.db.audiobooks
}
get libraries() {
return this.db.libraries
}
get 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() {
Logger.info('[Server] Init')
Logger.info('[Server] Init v' + version)
await this.streamManager.ensureStreamsDir()
await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
@ -170,105 +99,66 @@ class Server {
await this.purgeMetadata()
this.watcher.initWatcher()
this.watcher.initWatcher(this.libraries)
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() {
Logger.info('=== Starting Server ===')
await this.init()
const app = express()
this.expressApp = app
this.server = http.createServer(app)
app.use(this.auth.cors)
app.use(fileUpload())
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
if (process.env.NODE_ENV === 'production') {
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.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('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library', (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/: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('/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) {
res.sendStatus(200)
}
@ -407,8 +434,6 @@ class Server {
const initialPayload = {
serverSettings: this.serverSettings.toJSON(),
isScanning: this.isScanning,
isInitialized: this.isInitialized,
audiobookPath: this.AudiobookPath,
metadataPath: this.MetadataPath,
configPath: this.ConfigPath,

View File

@ -4,107 +4,184 @@ const Watcher = require('watcher')
const Logger = require('./Logger')
class FolderWatcher extends EventEmitter {
constructor(audiobookPath) {
constructor() {
super()
this.AudiobookPath = audiobookPath
this.folderMap = {}
this.watcher = null
this.paths = [] // Not used
this.pendingFiles = [] // Not used
this.pendingFiles = []
this.libraryWatchers = []
this.pendingFileUpdates = []
this.pendingDelay = 4000
this.pendingTimeout = null
}
initWatcher() {
try {
Logger.info('[FolderWatcher] Initializing..')
this.watcher = new Watcher(this.AudiobookPath, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
renameDetection: true,
renameTimeout: 2000,
recursive: true,
ignoreInitial: true,
persistent: true
get pendingFilePaths() {
return this.pendingFileUpdates.map(f => f.path)
}
buildLibraryWatcher(library) {
if (this.libraryWatchers.find(w => w.id === library.id)) {
Logger.warn('[Watcher] Already watching library', library.name)
return
}
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.onNewFile(path)
}).on('change', (path) => {
// This is triggered from metadata changes, not what we want
// this.onFileUpdated(path)
}).on('unlink', path => {
this.onFileRemoved(path)
}).on('rename', (path, pathNext) => {
this.onRename(path, pathNext)
}).on('error', (error) => {
Logger.error(`[FolderWatcher] ${error}`)
}).on('ready', () => {
Logger.info('[FolderWatcher] Ready')
})
} catch (error) {
Logger.error('Chokidar watcher failed', error)
this.libraryWatchers.push({
id: library.id,
name: library.name,
folders: library.folders,
paths: library.folderPaths,
watcher
})
}
initWatcher(libraries) {
libraries.forEach((lib) => {
this.buildLibraryWatcher(lib)
})
}
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() {
return this.watcher.close()
return this.libraryWatchers.map(lib => lib.watcher.close())
}
// After [pendingBatchDelay] seconds emit batch
async onNewFile(path) {
if (this.pendingFiles.includes(path)) return
Logger.debug('FolderWatcher: New File', path)
var dir = Path.dirname(path)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(path)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
onNewFile(libraryId, path) {
Logger.debug('[Watcher] File Added', path)
this.addFileUpdate(libraryId, path, 'added')
}
onFileRemoved(path) {
Logger.debug('[FolderWatcher] File Removed', path)
onFileRemoved(libraryId, 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)
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)
// this.pendingFiles.push(path)
// clearTimeout(this.pendingTimeout)
// this.pendingTimeout = setTimeout(() => {
// this.emit('files', this.pendingFiles.map(f => f))
// this.pendingFiles = []
// }, this.pendingDelay)
}
onFileUpdated(path) {
Logger.debug('[FolderWatcher] Updated File', path)
Logger.debug('[Watcher] Updated File', path)
}
onRename(pathFrom, pathTo) {
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
onRename(libraryId, 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)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
// this.pendingFiles.push(pathTo)
// clearTimeout(this.pendingTimeout)
// 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
}
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)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
this.emit('files', this.pendingFileUpdates)
this.pendingFileUpdates = []
}, this.pendingDelay)
}
}

View File

@ -34,9 +34,6 @@ class AudioFile {
this.exclude = false
this.error = null
// TEMP: For forcing rescan
this.isOldAudioFile = false
if (data) {
this.construct(data)
}
@ -103,7 +100,6 @@ class AudioFile {
// Old version of AudioFile used `tagAlbum` etc.
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
if (isOldVersion) {
this.isOldAudioFile = true
this.metadata = new AudioFileMetadata(data)
} else {
this.metadata = new AudioFileMetadata(data.metadata || {})

View File

@ -1,4 +1,5 @@
const Path = require('path')
const fs = require('fs-extra')
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
@ -14,11 +15,15 @@ class Audiobook {
this.id = null
this.ino = null // Inode
this.libraryId = null
this.folderId = null
this.path = null
this.fullPath = null
this.addedAt = null
this.lastUpdate = null
this.lastScan = null
this.scanVersion = null
this.tracks = []
this.missingParts = []
@ -41,11 +46,14 @@ class Audiobook {
construct(audiobook) {
this.id = audiobook.id
this.ino = audiobook.ino || null
this.libraryId = audiobook.libraryId || 'main'
this.folderId = audiobook.folderId || 'audiobooks'
this.path = audiobook.path
this.fullPath = audiobook.fullPath
this.addedAt = audiobook.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.missingParts = audiobook.missingParts
@ -127,10 +135,6 @@ class Audiobook {
return !!this._audioFiles.find(af => af.embeddedCoverArt)
}
get hasDescriptionTextFile() {
return !!this._otherFiles.find(of => of.filename === 'desc.txt')
}
bookToJSON() {
return this.book ? this.book.toJSON() : null
}
@ -144,6 +148,8 @@ class Audiobook {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
title: this.title,
author: this.author,
cover: this.cover,
@ -151,6 +157,8 @@ class Audiobook {
fullPath: this.fullPath,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
missingParts: this.missingParts,
tags: this.tags,
book: this.bookToJSON(),
@ -166,6 +174,8 @@ class Audiobook {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
book: this.bookToJSON(),
tags: this.tags,
path: this.path,
@ -188,6 +198,9 @@ class Audiobook {
toJSONExpanded() {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
@ -284,13 +297,10 @@ class Audiobook {
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) {
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.path = data.path
@ -307,7 +317,26 @@ class Audiobook {
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) {
// 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.setData(data)
}
@ -432,12 +461,13 @@ class Audiobook {
}
// 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 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)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
@ -448,9 +478,9 @@ class Audiobook {
hasUpdates = true
}
// If desc.txt is new or forcing rescan then read it and update description if empty
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
if (descriptionTxt && (!alreadyHadDescTxt || forceRescan)) {
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
var descriptionTxt = this.otherFiles.find(file => file.filename === 'desc.txt')
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
var newDescription = await readTextFile(descriptionTxt.fullPath)
if (newDescription) {
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
@ -458,10 +488,19 @@ class Audiobook {
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) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino)
if (!existingOtherFile) {
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
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')
// 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')) {
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) {
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
}
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 (!this.book.cover && imageFiles.length) {
this.book.cover = Path.join('/local', imageFiles[0].path)
Logger.info(`[Audiobook] Local cover was set | "${this.title}"`)
var imagePathRelativeToBook = imageFiles[0].path.replace(this.path, '')
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
}
return hasUpdates
@ -582,6 +676,12 @@ class Audiobook {
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
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)
if (success) {
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
@ -591,16 +691,32 @@ class Audiobook {
return false
}
// If desc.txt exists then use it as description
async saveDescriptionFromTextFile() {
var descriptionTextFile = this.otherFiles.find(file => file.filename === 'desc.txt')
if (!descriptionTextFile) return false
var newDescription = await readTextFile(descriptionTextFile.fullPath)
if (!newDescription) return false
return this.update({ book: { description: newDescription } })
// Look for desc.txt and reader.txt and update details if found
async saveDataFromTextFiles() {
var bookUpdatePayload = {}
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
if (descriptionText) {
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
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() {
if (!this.audioFiles.length) return false
var audioFile = this.audioFiles[0]

View File

@ -18,6 +18,7 @@ class Book {
this.publisher = null
this.description = null
this.cover = null
this.coverFullPath = null
this.genres = []
this.lastUpdate = null
@ -46,6 +47,7 @@ class Book {
this.publisher = book.publisher
this.description = book.description
this.cover = book.cover
this.coverFullPath = book.coverFullPath || null
this.genres = book.genres
this.lastUpdate = book.lastUpdate || Date.now()
}
@ -65,6 +67,7 @@ class Book {
publisher: this.publisher,
description: this.description,
cover: this.cover,
coverFullPath: this.coverFullPath,
genres: this.genres,
lastUpdate: this.lastUpdate
}
@ -100,20 +103,13 @@ class Book {
this.publishYear = data.publishYear || null
this.description = data.description || null
this.cover = data.cover || null
this.coverFullPath = data.coverFullPath || null
this.genres = data.genres || []
this.lastUpdate = Date.now()
if (data.author) {
this.setParseAuthor(this.author)
}
// Use first image file as cover
if (data.otherFiles && data.otherFiles.length) {
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
if (imageFile) {
this.cover = Path.normalize(Path.join('/local', imageFile.path))
}
}
}
update(payload) {
@ -168,6 +164,12 @@ class Book {
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
// May be worthwhile checking if these were manually updated and not override manual updates
syncPathsUpdated(audiobookData) {

36
server/objects/Folder.js Normal file
View 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
View 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

View File

@ -8,6 +8,7 @@ class ServerSettings {
this.autoTagNew = false
this.newTagExpireDays = 15
this.scannerParseSubtitle = false
this.scannerFindCovers = false
this.coverDestination = CoverDestination.METADATA
this.saveMetadataFile = false
this.rateLimitLoginRequests = 10
@ -22,6 +23,7 @@ class ServerSettings {
construct(settings) {
this.autoTagNew = settings.autoTagNew
this.newTagExpireDays = settings.newTagExpireDays
this.scannerFindCovers = !!settings.scannerFindCovers
this.scannerParseSubtitle = settings.scannerParseSubtitle
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
this.saveMetadataFile = !!settings.saveMetadataFile
@ -39,6 +41,7 @@ class ServerSettings {
id: this.id,
autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays,
scannerFindCovers: this.scannerFindCovers,
scannerParseSubtitle: this.scannerParseSubtitle,
coverDestination: this.coverDestination,
saveMetadataFile: !!this.saveMetadataFile,

View File

@ -192,7 +192,6 @@ module.exports.scanAudioFiles = scanAudioFiles
async function rescanAudioFiles(audiobook) {
var audioFiles = audiobook.audioFiles
var updates = 0
@ -215,7 +214,7 @@ async function rescanAudioFiles(audiobook) {
// Fallback to checking path
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
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.syncMetadata(audioFile)
} else {

View File

@ -23,6 +23,8 @@ function isAudioFile(path) {
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) {
// 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)
@ -110,25 +112,26 @@ function getFileType(ext) {
return 'unknown'
}
// Primary scan: abRootPath is /audiobooks
async function scanRootDir(abRootPath, serverSettings = {}) {
// Scan folder
async function scanRootDir(folder, serverSettings = {}) {
var folderPath = folder.fullPath
var parseSubtitle = !!serverSettings.scannerParseSubtitle
var pathdata = await getPaths(abRootPath)
var pathdata = await getPaths(folderPath)
var filepaths = pathdata.files.map(filepath => {
return Path.normalize(filepath).replace(abRootPath, '')
return Path.normalize(filepath).replace(folderPath, '')
})
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
if (!Object.keys(audiobookGrouping).length) {
Logger.error('Root path has no audiobooks')
Logger.error('Root path has no audiobooks', filepaths)
return []
}
var audiobooks = []
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])
for (let i = 0; i < fileObjs.length; i++) {
@ -136,6 +139,8 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
}
var audiobookIno = await getIno(audiobookData.fullPath)
audiobooks.push({
folderId: folder.id,
libraryId: folder.libraryId,
ino: audiobookIno,
...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
@ -147,7 +152,7 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
module.exports.scanRootDir = scanRootDir
// 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)
// Audio files will always be in the directory named for the title
@ -218,11 +223,11 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
volumeNumber,
publishYear,
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 paths = await getPaths(audiobookPath)
@ -235,9 +240,11 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
return pathsA - pathsB
})
var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1)
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle)
var audiobookDir = Path.normalize(audiobookPath).replace(folder.fullPath, '').slice(1)
var audiobookData = getAudiobookDataFromDir(folder.fullPath, audiobookDir, parseSubtitle)
var audiobook = {
folderId: folder.id,
libraryId: folder.libraryId,
...audiobookData,
audioFiles: [],
otherFiles: []
@ -246,7 +253,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
for (let i = 0; i < filepaths.length; 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 basename = Path.basename(filepath)
var ino = await getIno(filepath)