mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-06 06:31:19 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a743ccc1 | ||
|
|
09dc95f560 | ||
|
|
853858825b | ||
|
|
c962090c3a | ||
|
|
63a8e2433e | ||
|
|
f78d287b59 | ||
|
|
eaa383b6d8 | ||
|
|
113026ce13 | ||
|
|
578a946ca5 | ||
|
|
f31306eda0 | ||
|
|
c62b716a2c | ||
|
|
97ed20c683 | ||
|
|
d5c46dcbfb | ||
|
|
30934edd57 | ||
|
|
d06fd1a1b1 | ||
|
|
6bb36381f1 |
@@ -3,7 +3,6 @@ set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
DEFAULT_HOST="0.0.0.0"
|
||||
@@ -54,14 +53,6 @@ setup_config_interactive() {
|
||||
if should_build_config; then
|
||||
echo "Okay, let's setup a new config."
|
||||
|
||||
AUDIOBOOK_PATH=""
|
||||
read -p "
|
||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
||||
|
||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
||||
fi
|
||||
|
||||
DATA_PATH=""
|
||||
read -p "
|
||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
||||
@@ -78,8 +69,7 @@ setup_config_interactive() {
|
||||
PORT="$DEFAULT_PORT"
|
||||
fi
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DATA_PATH/metadata
|
||||
config_text="METADATA_PATH=$DATA_PATH/metadata
|
||||
CONFIG_PATH=$DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
@@ -102,8 +92,7 @@ setup_config() {
|
||||
else
|
||||
echo "Creating default config."
|
||||
|
||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<ui-libraries-dropdown />
|
||||
|
||||
<controls-global-search class="hidden md:block" />
|
||||
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||
@@ -24,11 +24,11 @@
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
@@ -38,7 +41,7 @@ export default {
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
const configRoutes = [
|
||||
{
|
||||
id: 'config',
|
||||
title: 'Settings',
|
||||
@@ -63,18 +66,23 @@ export default {
|
||||
id: 'config-log',
|
||||
title: 'Log',
|
||||
path: '/config/log'
|
||||
},
|
||||
{
|
||||
}
|
||||
]
|
||||
|
||||
if (this.currentLibraryId) {
|
||||
configRoutes.push({
|
||||
id: 'config-library-stats',
|
||||
title: 'Library Stats',
|
||||
path: '/config/library-stats'
|
||||
},
|
||||
{
|
||||
})
|
||||
configRoutes.push({
|
||||
id: 'config-stats',
|
||||
title: 'Your Stats',
|
||||
path: '/config/stats'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return configRoutes
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
|
||||
<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" />
|
||||
@@ -79,6 +82,13 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
isShowingBookshelfToolbar() {
|
||||
if (!this.$route.name) return false
|
||||
return this.$route.name.startsWith('library')
|
||||
},
|
||||
offsetTop() {
|
||||
return 64
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
|
||||
@@ -65,6 +65,9 @@ export default {
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
},
|
||||
asin() {
|
||||
return this._author.asin || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
},
|
||||
@@ -81,7 +84,11 @@ export default {
|
||||
},
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||
const payload = {}
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -442,7 +442,34 @@ export default {
|
||||
this.isSelectionMode = val
|
||||
if (!val) this.selected = false
|
||||
},
|
||||
setEntity(libraryItem) {
|
||||
setEntity(_libraryItem) {
|
||||
var libraryItem = _libraryItem
|
||||
|
||||
// this code block is only necessary when showing a selected series with sequence #
|
||||
// it will update the selected series so we get realtime updates for series sequence changes
|
||||
if (this.series) {
|
||||
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||
libraryItem = {
|
||||
..._libraryItem,
|
||||
media: {
|
||||
..._libraryItem.media,
|
||||
metadata: {
|
||||
..._libraryItem.media.metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
if (mediaMetadata.series) {
|
||||
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||
if (newSeries) {
|
||||
// update selected series
|
||||
libraryItem.media.metadata.series = newSeries
|
||||
this.libraryItem = libraryItem
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<!-- <div class="w-40 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
||||
</div> -->
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
authorName: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
isProcessing: false,
|
||||
provider: 'audnexus',
|
||||
providers: [
|
||||
{
|
||||
text: 'Audnexus',
|
||||
value: 'audnexus'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
authorName: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.searchAuthor = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
return `q=${this.searchAuthor}`
|
||||
},
|
||||
submitSearch() {
|
||||
if (!this.searchAuthor) {
|
||||
this.$toast.warning('Author name is required')
|
||||
return
|
||||
}
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (result) {
|
||||
this.$emit('match', result)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -139,12 +139,17 @@ export default {
|
||||
this.processing = false
|
||||
},
|
||||
async searchAuthor() {
|
||||
if (!this.authorCopy.name) {
|
||||
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||
this.$toast.error('Must enter an author name')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
||||
|
||||
const payload = {}
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -95,7 +95,7 @@ export default {
|
||||
settings: {
|
||||
disableWatcher: false,
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
skipMatchingMediaWithIsbn: false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -193,6 +193,11 @@ export default {
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||
if (!this.$store.state.libraries.currentLibraryId) {
|
||||
console.log('Setting initially library id', res.id)
|
||||
// First library added
|
||||
this.$store.dispatch('libraries/fetch', res.id)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
|
||||
@@ -148,6 +148,7 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
var episode = this.episodes[i]
|
||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||
|
||||
@@ -151,7 +151,7 @@ export default {
|
||||
this.fullPath = ''
|
||||
return
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||
},
|
||||
submit() {
|
||||
const podcastPayload = {
|
||||
|
||||
@@ -6,18 +6,20 @@
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||
<template v-for="library in libraryCopies">
|
||||
<div :key="library.id" class="item">
|
||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
<div v-if="!libraries.length" class="pb-4">
|
||||
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
|
||||
</div>
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||
|
||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,8 +34,6 @@ export default {
|
||||
return {
|
||||
libraryCopies: [],
|
||||
currentOrder: [],
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null,
|
||||
drag: false,
|
||||
dragOptions: {
|
||||
animation: 200,
|
||||
@@ -97,12 +97,10 @@ export default {
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
},
|
||||
clickAddLibrary() {
|
||||
this.selectedLibrary = null
|
||||
this.showLibraryModal = true
|
||||
this.$emit('showLibraryModal', null)
|
||||
},
|
||||
editLibrary(library) {
|
||||
this.selectedLibrary = library
|
||||
this.showLibraryModal = true
|
||||
this.$emit('showLibraryModal', library)
|
||||
},
|
||||
init() {
|
||||
this.libraryCopies = this.libraries.map((lib) => {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div ref="wrapper" class="relative">
|
||||
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +34,10 @@ export default {
|
||||
clearable: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showPassword: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
@@ -49,6 +55,10 @@ export default {
|
||||
if (this.noSpinner) _list.push('no-spinner')
|
||||
if (this.textCenter) _list.push('text-center')
|
||||
return _list.join(' ')
|
||||
},
|
||||
actualType() {
|
||||
if (this.type === 'password' && this.showPassword) return 'text'
|
||||
return this.type
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -69,9 +79,20 @@ export default {
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.type === 'password' && this.$refs.wrapper) {
|
||||
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
|
||||
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</p>
|
||||
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||
<div class="flex" :style="{ height: height + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<cards-lazy-book-card :key="item.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||
<app-appbar />
|
||||
|
||||
<Nuxt />
|
||||
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
||||
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
|
||||
<Nuxt />
|
||||
</div>
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
|
||||
@@ -45,6 +48,13 @@ export default {
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.globals.isCasting
|
||||
},
|
||||
isShowingSideRail() {
|
||||
if (!this.$route.name) return false
|
||||
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
appContentMarginLeft() {
|
||||
return this.isShowingSideRail ? 80 : 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -163,6 +173,7 @@ export default {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
},
|
||||
async libraryRemoved(library) {
|
||||
console.log('Library removed', library)
|
||||
this.$store.commit('libraries/remove', library)
|
||||
|
||||
// When removed currently selected library then set next accessible library
|
||||
@@ -181,18 +192,20 @@ export default {
|
||||
this.$router.push(`/library/${nextLibrary.id}`)
|
||||
}
|
||||
} else {
|
||||
console.error('User has no accessible libraries')
|
||||
console.error('User has no more accessible libraries')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||
}
|
||||
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
||||
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||
},
|
||||
libraryItemRemoved(item) {
|
||||
if (this.$route.name.startsWith('item')) {
|
||||
@@ -550,4 +563,13 @@ export default {
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
width: 100%;
|
||||
}
|
||||
#app-content.has-siderail {
|
||||
width: calc(100% - 80px);
|
||||
max-width: calc(100% - 80px);
|
||||
margin-left: 80px;
|
||||
}
|
||||
</style>
|
||||
2305
client/package-lock.json
generated
2305
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.0.13",
|
||||
"description": "Audiobook manager and player",
|
||||
"version": "2.0.14",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nuxt",
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<tables-library-libraries-table />
|
||||
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
|
||||
|
||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
methods: {
|
||||
setShowLibraryModal(selectedLibrary) {
|
||||
this.selectedLibrary = selectedLibrary
|
||||
this.showLibraryModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -67,6 +67,12 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/config')
|
||||
}
|
||||
return {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
libraryStats: null
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/oops?message=No libraries')
|
||||
}
|
||||
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<template v-for="author in authors">
|
||||
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||
</div>
|
||||
</div>
|
||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<form @submit.prevent="submit" class="flex">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<app-book-shelf-toolbar page="podcast-search" />
|
||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||
<div class="w-full max-w-3xl mx-auto">
|
||||
<form @submit.prevent="submit" class="flex">
|
||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||
<template v-for="podcast in results">
|
||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full max-w-3xl mx-auto py-4">
|
||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||
<template v-for="podcast in results">
|
||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4 max-w-2xl">
|
||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||
<div v-else class="w-full py-16">
|
||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||
<div v-else class="w-full py-16">
|
||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail class="hidden md:block" />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :selected-series="series" />
|
||||
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
||||
</div>
|
||||
</div>
|
||||
<app-book-shelf-toolbar :selected-series="series" />
|
||||
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
<template>
|
||||
<div class="w-full h-screen bg-bg">
|
||||
<div class="w-full flex h-1/2 items-center justify-center">
|
||||
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4">
|
||||
<div class="w-full flex h-full items-center justify-center">
|
||||
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
|
||||
<p class="text-center text-lg font-semibold">Server could not be reached</p>
|
||||
</div>
|
||||
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
|
||||
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<form @submit.prevent="submitServerSetup">
|
||||
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||
|
||||
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
|
||||
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||
<p class="text-3xl text-white text-center mb-4">Login</p>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
@@ -11,8 +33,8 @@
|
||||
|
||||
<label class="text-xs text-gray-300 uppercase">Password</label>
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
||||
<div class="w-full flex justify-end">
|
||||
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button>
|
||||
<div class="w-full flex justify-end py-3">
|
||||
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -26,15 +48,33 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
error: null,
|
||||
criticalError: null,
|
||||
processing: false,
|
||||
username: '',
|
||||
password: null
|
||||
password: null,
|
||||
showInitScreen: false,
|
||||
isInit: false,
|
||||
newRoot: {
|
||||
username: 'root',
|
||||
password: ''
|
||||
},
|
||||
confirmPassword: '',
|
||||
ConfigPath: '',
|
||||
MetadataPath: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
user(newVal) {
|
||||
if (newVal) {
|
||||
if (this.$route.query.redirect) {
|
||||
if (!this.$store.state.libraries.currentLibraryId) {
|
||||
// No libraries available to this user
|
||||
if (this.$store.getters['user/getIsRoot']) {
|
||||
// If root user go to config/libraries
|
||||
this.$router.replace('/config/libraries')
|
||||
} else {
|
||||
this.$router.replace('/oops?message=No libraries available')
|
||||
}
|
||||
} else if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||
@@ -48,6 +88,42 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitServerSetup() {
|
||||
if (!this.newRoot.username || !this.newRoot.username.trim()) {
|
||||
this.$toast.error('Must enter a root username')
|
||||
return
|
||||
}
|
||||
if (this.newRoot.password !== this.confirmPassword) {
|
||||
this.$toast.error('Password mismatch')
|
||||
return
|
||||
}
|
||||
if (!this.newRoot.password) {
|
||||
if (!confirm('Are you sure you want to create the root user with no password?')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
newRoot: { ...this.newRoot }
|
||||
}
|
||||
var success = await this.$axios
|
||||
.$post('/init', payload)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error.response)
|
||||
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errorMsg)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
this.processing = false
|
||||
return
|
||||
}
|
||||
|
||||
location.reload()
|
||||
},
|
||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
|
||||
@@ -81,32 +157,54 @@ export default {
|
||||
this.processing = false
|
||||
},
|
||||
checkAuth() {
|
||||
if (localStorage.getItem('token')) {
|
||||
var token = localStorage.getItem('token')
|
||||
var token = localStorage.getItem('token')
|
||||
if (!token) return false
|
||||
|
||||
if (token) {
|
||||
this.processing = true
|
||||
this.processing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authorize error', error)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
return this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authorize error', error)
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
},
|
||||
checkStatus() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get('/status')
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.isInit = res.isInit
|
||||
this.showInitScreen = !res.isInit
|
||||
if (this.showInitScreen) {
|
||||
this.ConfigPath = res.ConfigPath || ''
|
||||
this.MetadataPath = res.MetadataPath || ''
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Status check failed', error)
|
||||
this.processing = false
|
||||
this.criticalError = 'Status check failed'
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.checkAuth()
|
||||
async mounted() {
|
||||
if (localStorage.getItem('token')) {
|
||||
var userfound = await this.checkAuth()
|
||||
if (userfound) return // if valid user no need to check status
|
||||
}
|
||||
this.checkStatus()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -114,10 +114,11 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||
if (typeof input !== 'string') {
|
||||
return false
|
||||
}
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||
var reservedRe = /^\.+$/;
|
||||
@@ -125,6 +126,7 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
||||
var windowsTrailingRe = /[\. ]+$/;
|
||||
|
||||
var sanitized = input
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
|
||||
@@ -2,7 +2,7 @@ export const state = () => ({
|
||||
libraries: [],
|
||||
lastLoad: 0,
|
||||
listeners: [],
|
||||
currentLibraryId: 'main',
|
||||
currentLibraryId: null,
|
||||
folders: [],
|
||||
issues: 0,
|
||||
folderLastUpdate: 0,
|
||||
@@ -206,11 +206,11 @@ export const mutations = {
|
||||
setLibraryFilterData(state, filterData) {
|
||||
state.filterData = filterData
|
||||
},
|
||||
updateFilterDataWithAudiobook(state, audiobook) {
|
||||
if (!audiobook || !audiobook.book || !state.filterData) return
|
||||
if (state.currentLibraryId !== audiobook.libraryId) return
|
||||
updateFilterDataWithItem(state, libraryItem) {
|
||||
if (!libraryItem || !state.filterData) return
|
||||
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||
/*
|
||||
var filterdata = {
|
||||
var data = {
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
@@ -219,36 +219,70 @@ export const mutations = {
|
||||
languages: []
|
||||
}
|
||||
*/
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
|
||||
if (audiobook.book.authorFL) {
|
||||
audiobook.book.authorFL.split(', ').forEach((author) => {
|
||||
if (author && !state.filterData.authors.includes(author)) {
|
||||
// Add/update book authors
|
||||
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||
mediaMetadata.authors.forEach((author) => {
|
||||
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
|
||||
if (indexOf >= 0) {
|
||||
state.filterData.authors.splice(indexOf, 1, author)
|
||||
} else {
|
||||
state.filterData.authors.push(author)
|
||||
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||
}
|
||||
})
|
||||
}
|
||||
if (audiobook.book.narratorFL) {
|
||||
audiobook.book.narratorFL.split(', ').forEach((narrator) => {
|
||||
if (narrator && !state.filterData.narrators.includes(narrator)) {
|
||||
|
||||
// Add/update series
|
||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||
mediaMetadata.series.forEach((series) => {
|
||||
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
|
||||
if (indexOf >= 0) {
|
||||
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
|
||||
} else {
|
||||
state.filterData.series.push({ id: series.id, name: series.name })
|
||||
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add genres
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
mediaMetadata.genres.forEach((genre) => {
|
||||
if (!state.filterData.genres.includes(genre)) {
|
||||
state.filterData.genres.push(genre)
|
||||
state.filterData.genres.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add tags
|
||||
if (libraryItem.media.tags && libraryItem.media.tags.length) {
|
||||
libraryItem.media.tags.forEach((tag) => {
|
||||
if (!state.filterData.tags.includes(tag)) {
|
||||
state.filterData.tags.push(tag)
|
||||
state.filterData.tags.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add narrators
|
||||
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
|
||||
mediaMetadata.narrators.forEach((narrator) => {
|
||||
if (!state.filterData.narrators.includes(narrator)) {
|
||||
state.filterData.narrators.push(narrator)
|
||||
state.filterData.narrators.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
})
|
||||
}
|
||||
if (audiobook.book.series && !state.filterData.series.includes(audiobook.book.series)) {
|
||||
state.filterData.series.push(audiobook.book.series)
|
||||
}
|
||||
if (audiobook.tags && audiobook.tags.length) {
|
||||
audiobook.tags.forEach((tag) => {
|
||||
if (tag && !state.filterData.tags.includes(tag)) state.filterData.tags.push(tag)
|
||||
})
|
||||
}
|
||||
if (audiobook.book.genres && audiobook.book.genres.length) {
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
|
||||
})
|
||||
}
|
||||
if (audiobook.book.language && !state.filterData.languages.includes(audiobook.book.language)) {
|
||||
state.filterData.languages.push(audiobook.book.language)
|
||||
|
||||
// Add language
|
||||
if (mediaMetadata.language) {
|
||||
if (!state.filterData.languages.includes(mediaMetadata.language)) {
|
||||
state.filterData.languages.push(mediaMetadata.language)
|
||||
state.filterData.languages.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
index.js
10
index.js
@@ -1,4 +1,4 @@
|
||||
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
|
||||
@@ -9,20 +9,20 @@ if (isDev) {
|
||||
process.env.PORT = devEnv.Port
|
||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||
process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||
process.env.SOURCE = 'local'
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
const HOST = process.env.HOST || '0.0.0.0'
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||
const UID = process.env.AUDIOBOOKSHELF_UID || 99
|
||||
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
||||
const SOURCE = process.env.SOURCE || 'docker'
|
||||
|
||||
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
||||
|
||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||
Server.start()
|
||||
|
||||
595
package-lock.json
generated
595
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.0.13",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"version": "2.0.14",
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
|
||||
13
prod.js
13
prod.js
@@ -1,16 +1,16 @@
|
||||
const optionDefinitions = [
|
||||
{ name: 'config', alias: 'c', type: String },
|
||||
{ name: 'audiobooks', alias: 'a', type: String },
|
||||
{ name: 'metadata', alias: 'm', type: String },
|
||||
{ name: 'port', alias: 'p', type: String },
|
||||
{ name: 'host', alias: 'h', type: String }
|
||||
{ name: 'host', alias: 'h', type: String },
|
||||
{ name: 'source', alias: 's', type: String }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('command-line-args')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
@@ -18,18 +18,17 @@ const server = require('./server/Server')
|
||||
global.appRoot = __dirname
|
||||
|
||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
||||
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
|
||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||
|
||||
const PORT = options.port || process.env.PORT || 3333
|
||||
const HOST = options.host || process.env.HOST || "0.0.0.0"
|
||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||
const UID = 99
|
||||
const GID = 100
|
||||
const SOURCE = options.source || 'debian'
|
||||
|
||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
|
||||
|
||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||
Server.start()
|
||||
|
||||
@@ -64,7 +64,7 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
||||
Available in Unraid Community Apps
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/advplyr/audiobookshelf
|
||||
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||
|
||||
docker run -d \
|
||||
-e AUDIOBOOKSHELF_UID=99 \
|
||||
@@ -75,14 +75,14 @@ docker run -d \
|
||||
-v </path/to/config>:/config \
|
||||
-v </path/to/metadata>:/metadata \
|
||||
--name audiobookshelf \
|
||||
ghcr.io/advplyr/audiobookshelf
|
||||
ghcr.io/advplyr/audiobookshelf:latest
|
||||
```
|
||||
|
||||
### Docker Update
|
||||
|
||||
```bash
|
||||
docker stop audiobookshelf
|
||||
docker pull ghcr.io/advplyr/audiobookshelf
|
||||
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||
docker start audiobookshelf
|
||||
```
|
||||
|
||||
@@ -92,7 +92,7 @@ docker start audiobookshelf
|
||||
### docker-compose.yml ###
|
||||
services:
|
||||
audiobookshelf:
|
||||
image: ghcr.io/advplyr/audiobookshelf
|
||||
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||
environment:
|
||||
- AUDIOBOOKSHELF_UID=99
|
||||
- AUDIOBOOKSHELF_GID=100
|
||||
|
||||
@@ -17,14 +17,6 @@ class Auth {
|
||||
return this.db.users
|
||||
}
|
||||
|
||||
init() {
|
||||
var root = this.users.find(u => u.type === 'root')
|
||||
if (!root) {
|
||||
Logger.fatal('No Root User', this.users)
|
||||
throw new Error('No Root User')
|
||||
}
|
||||
}
|
||||
|
||||
cors(req, res, next) {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||
|
||||
68
server/Db.js
68
server/Db.js
@@ -46,6 +46,10 @@ class Db {
|
||||
this.previousVersion = null
|
||||
}
|
||||
|
||||
get hasRootUser() {
|
||||
return this.users.some(u => u.id === 'root')
|
||||
}
|
||||
|
||||
getEntityDb(entityName) {
|
||||
if (entityName === 'user') return this.usersDb
|
||||
else if (entityName === 'session') return this.sessionsDb
|
||||
@@ -70,33 +74,6 @@ class Db {
|
||||
return null
|
||||
}
|
||||
|
||||
getDefaultUser(token) {
|
||||
return new User({
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
username: 'root',
|
||||
pash: '',
|
||||
stream: null,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
getDefaultLibrary() {
|
||||
var defaultLibrary = new Library()
|
||||
defaultLibrary.setData({
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
folder: { // Generates default folder
|
||||
id: 'audiobooks',
|
||||
fullPath: global.AudiobookPath,
|
||||
libraryId: 'main'
|
||||
}
|
||||
})
|
||||
return defaultLibrary
|
||||
}
|
||||
|
||||
reinit() {
|
||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
@@ -123,23 +100,36 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
createRootUser(username, pash, token) {
|
||||
const newRoot = new User({
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
username,
|
||||
pash,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
return this.insertEntity('user', newRoot)
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
// Insert Defaults
|
||||
var rootUser = this.users.find(u => u.type === 'root')
|
||||
if (!rootUser) {
|
||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||
Logger.debug('Generated default token', token)
|
||||
Logger.info('[Db] Root user created')
|
||||
await this.insertEntity('user', this.getDefaultUser(token))
|
||||
} else {
|
||||
Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
||||
}
|
||||
// var rootUser = this.users.find(u => u.type === 'root')
|
||||
// if (!rootUser) {
|
||||
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||
// Logger.debug('Generated default token', token)
|
||||
// Logger.info('[Db] Root user created')
|
||||
// await this.insertEntity('user', this.getDefaultUser(token))
|
||||
// } else {
|
||||
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
||||
// }
|
||||
|
||||
if (!this.libraries.length) {
|
||||
await this.insertEntity('library', this.getDefaultLibrary())
|
||||
}
|
||||
// if (!this.libraries.length) {
|
||||
// await this.insertEntity('library', this.getDefaultLibrary())
|
||||
// }
|
||||
|
||||
if (!this.serverSettings) {
|
||||
this.serverSettings = new ServerSettings()
|
||||
|
||||
@@ -34,18 +34,18 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||
this.Source = SOURCE
|
||||
this.Port = PORT
|
||||
this.Host = HOST
|
||||
global.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||
global.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||
global.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
|
||||
// Fix backslash if not on Windows
|
||||
if (process.platform !== 'win32') {
|
||||
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
||||
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
|
||||
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
@@ -57,10 +57,6 @@ class Server {
|
||||
fs.mkdirSync(global.MetadataPath)
|
||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||
}
|
||||
if (!fs.pathExistsSync(global.AudiobookPath)) {
|
||||
fs.mkdirSync(global.AudiobookPath)
|
||||
filePerms.setDefaultDirSync(global.AudiobookPath, false)
|
||||
}
|
||||
|
||||
this.db = new Db()
|
||||
this.watcher = new Watcher()
|
||||
@@ -140,10 +136,9 @@ class Server {
|
||||
await this.db.init()
|
||||
}
|
||||
|
||||
this.auth.init()
|
||||
|
||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
||||
await this.purgeMetadata() // Remove metadata folders without library item
|
||||
await this.cacheManager.ensureCachePaths()
|
||||
|
||||
await this.backupManager.init()
|
||||
await this.logManager.init()
|
||||
@@ -214,18 +209,42 @@ class Server {
|
||||
})
|
||||
|
||||
// Client dynamic routes
|
||||
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library/search', (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.get('/library/:library/authors', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library/series/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
'/item/:id/manage',
|
||||
'/item/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/series/:id?',
|
||||
'/config/users/:id',
|
||||
'/collection/:id'
|
||||
]
|
||||
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
app.post('/init', (req, res) => {
|
||||
if (this.db.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.initializeServer(req, res)
|
||||
})
|
||||
app.get('/status', (req, res) => {
|
||||
// status check for client to see if server has been initialized
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
isInit: this.db.hasRootUser
|
||||
}
|
||||
if (!payload.isInit) {
|
||||
payload.ConfigPath = global.ConfigPath
|
||||
payload.MetadataPath = global.MetadataPath
|
||||
}
|
||||
res.json(payload)
|
||||
})
|
||||
app.get('/ping', (req, res) => {
|
||||
Logger.info('Recieved ping')
|
||||
res.json({ success: true })
|
||||
@@ -288,6 +307,17 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
async initializeServer(req, res) {
|
||||
Logger.info(`[Server] Initializing new server`)
|
||||
const newRoot = req.body.newRoot
|
||||
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
|
||||
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async filesChanged(fileUpdates) {
|
||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||
await this.scanner.scanFilesChanged(fileUpdates)
|
||||
@@ -428,7 +458,6 @@ class Server {
|
||||
const initialPayload = {
|
||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
||||
serverSettings: this.db.serverSettings.toJSON(),
|
||||
audiobookPath: global.AudiobookPath,
|
||||
metadataPath: global.MetadataPath,
|
||||
configPath: global.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
|
||||
@@ -107,11 +107,16 @@ class AuthorController {
|
||||
}
|
||||
|
||||
async match(req, res) {
|
||||
var authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||
var authorData = null
|
||||
if (req.body.asin) {
|
||||
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
|
||||
} else {
|
||||
authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||
}
|
||||
if (!authorData) {
|
||||
return res.status(404).send('Author not found')
|
||||
}
|
||||
Logger.debug(`[AuthorController] match author with "${req.body.q}"`, authorData)
|
||||
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
|
||||
|
||||
var hasUpdates = false
|
||||
if (authorData.asin && req.author.asin !== authorData.asin) {
|
||||
@@ -121,6 +126,8 @@ class AuthorController {
|
||||
|
||||
// Only updates image if there was no image before or the author ASIN was updated
|
||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||
this.cacheManager.purgeImageCache(req.author.id)
|
||||
|
||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||
if (imageData) {
|
||||
req.author.imagePath = imageData.path
|
||||
|
||||
@@ -42,6 +42,7 @@ class LibraryController {
|
||||
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
||||
library.setData(newLibraryPayload)
|
||||
await this.db.insertEntity('library', library)
|
||||
// TODO: Only emit to users that have access
|
||||
this.emitter('library_added', library.toJSON())
|
||||
|
||||
// Add library watcher
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const axios = require('axios')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
||||
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
|
||||
class PodcastController {
|
||||
@@ -107,6 +108,12 @@ class PodcastController {
|
||||
if (!payload) {
|
||||
return res.status(500).send('Invalid podcast RSS feed')
|
||||
}
|
||||
|
||||
if (!payload.podcast.metadata.feedUrl) {
|
||||
// Not every RSS feed will put the feed url in their metadata
|
||||
payload.podcast.metadata.feedUrl = url
|
||||
}
|
||||
|
||||
res.json(payload)
|
||||
}).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
|
||||
@@ -19,6 +19,11 @@ class AuthorFinder {
|
||||
})
|
||||
}
|
||||
|
||||
findAuthorByASIN(asin) {
|
||||
if (!asin) return null
|
||||
return this.audnexus.findAuthorByASIN(asin)
|
||||
}
|
||||
|
||||
async findAuthorByName(name, options = {}) {
|
||||
if (!name) return null
|
||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const stream = require('stream')
|
||||
const filePerms = require('../utils/filePerms')
|
||||
const Logger = require('../Logger')
|
||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||
|
||||
@@ -9,6 +10,34 @@ class CacheManager {
|
||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||
|
||||
this.cachePathsExist = false
|
||||
}
|
||||
|
||||
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||
if (this.cachePathsExist) return
|
||||
|
||||
var pathsCreated = false
|
||||
if (!(await fs.pathExists(this.CachePath))) {
|
||||
await fs.mkdir(this.CachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||
await fs.mkdir(this.CoverCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||
await fs.mkdir(this.ImageCachePath)
|
||||
pathsCreated = true
|
||||
}
|
||||
|
||||
if (pathsCreated) {
|
||||
await filePerms.setDefault(this.CachePath)
|
||||
}
|
||||
|
||||
this.cachePathsExist = true
|
||||
}
|
||||
|
||||
async handleCoverCache(res, libraryItem, options = {}) {
|
||||
@@ -33,9 +62,6 @@ class CacheManager {
|
||||
return ps.pipe(res)
|
||||
}
|
||||
|
||||
// Write cache
|
||||
await fs.ensureDir(this.CoverCachePath)
|
||||
|
||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
@@ -43,6 +69,9 @@ class CacheManager {
|
||||
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(400)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
var readStream = fs.createReadStream(writtenFile)
|
||||
readStream.pipe(res)
|
||||
}
|
||||
@@ -56,8 +85,6 @@ class CacheManager {
|
||||
}
|
||||
|
||||
async purgeEntityCache(entityId, cachePath) {
|
||||
// If purgeAll has been called... The cover cache directory no longer exists
|
||||
await fs.ensureDir(cachePath)
|
||||
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||
if (file.startsWith(entityId)) {
|
||||
Logger.debug(`[CacheManager] Going to purge ${file}`);
|
||||
@@ -84,6 +111,7 @@ class CacheManager {
|
||||
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
||||
})
|
||||
}
|
||||
await this.ensureCachePaths()
|
||||
}
|
||||
|
||||
async handleAuthorCache(res, author, options = {}) {
|
||||
@@ -108,12 +136,12 @@ class CacheManager {
|
||||
return ps.pipe(res)
|
||||
}
|
||||
|
||||
// Write cache
|
||||
await fs.ensureDir(this.ImageCachePath)
|
||||
|
||||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||
if (!writtenFile) return res.sendStatus(400)
|
||||
|
||||
// Set owner and permissions of cache image
|
||||
await filePerms.setDefault(path)
|
||||
|
||||
var readStream = fs.createReadStream(writtenFile)
|
||||
readStream.pipe(res)
|
||||
}
|
||||
|
||||
@@ -242,13 +242,6 @@ class DownloadManager {
|
||||
if (shouldIncludeCover) {
|
||||
var _cover = audiobook.book.coverFullPath.replace(/\\/g, '/')
|
||||
|
||||
// Supporting old local file prefix
|
||||
var bookCoverPath = audiobook.book.cover ? audiobook.book.cover.replace(/\\/g, '/') : null
|
||||
if (!_cover && bookCoverPath && bookCoverPath.startsWith('/local')) {
|
||||
_cover = Path.posix.join(global.AudiobookPath, _cover.replace('/local', ''))
|
||||
Logger.debug('Local cover url', _cover)
|
||||
}
|
||||
|
||||
ffmpegInputs.push({
|
||||
input: _cover,
|
||||
options: ['-f image2pipe']
|
||||
|
||||
@@ -480,7 +480,6 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
||||
|
||||
release = await lock(store, lockoptions);
|
||||
|
||||
// console.log('Start updateStoreData for tempstore', tempstore, 'real store', store)
|
||||
const handlerResults = await new Promise((resolve, reject) => {
|
||||
|
||||
const writer = createWriteStream(tempstore);
|
||||
@@ -490,14 +489,11 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
||||
// Reader was opening and closing before writer ever opened
|
||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
||||
|
||||
// console.log('Writer opened for tempstore', tempstore)
|
||||
reader.on("line", record => {
|
||||
handler.next(record, writer)
|
||||
});
|
||||
|
||||
|
||||
reader.on("close", () => {
|
||||
// console.log('Closing reader for store', store)
|
||||
writer.end();
|
||||
resolve(handler.return());
|
||||
});
|
||||
@@ -505,12 +501,7 @@ const updateStoreData = async (store, match, update, tempstore, lockoptions) =>
|
||||
reader.on("error", error => reject(error));
|
||||
});
|
||||
|
||||
// writer.on('close', () => {
|
||||
// console.log('Writer closed for tempstore', tempstore)
|
||||
// })
|
||||
|
||||
writer.on("error", error => reject(error));
|
||||
|
||||
});
|
||||
|
||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
||||
@@ -579,26 +570,27 @@ const updateStoreDataSync = (store, match, update, tempstore) => {
|
||||
|
||||
const deleteStoreData = async (store, match, tempstore, lockoptions) => {
|
||||
let release, results;
|
||||
|
||||
release = await lock(store, lockoptions);
|
||||
|
||||
const handlerResults = await new Promise((resolve, reject) => {
|
||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
||||
const writer = createWriteStream(tempstore);
|
||||
const handler = Handler("delete", match);
|
||||
|
||||
writer.on("open", () => {
|
||||
// Create reader after writer opens otherwise the reader can sometimes close before the writer opens
|
||||
const reader = createInterface({ input: createReadStream(store), crlfDelay: Infinity });
|
||||
|
||||
reader.on("line", record => handler.next(record, writer));
|
||||
|
||||
reader.on("close", () => {
|
||||
writer.end();
|
||||
resolve(handler.return());
|
||||
});
|
||||
|
||||
reader.on("error", error => reject(error));
|
||||
});
|
||||
|
||||
writer.on("error", error => reject(error));
|
||||
|
||||
reader.on("close", () => {
|
||||
writer.end();
|
||||
resolve(handler.return());
|
||||
});
|
||||
|
||||
reader.on("error", error => reject(error));
|
||||
});
|
||||
|
||||
results = Object.assign({ store: store, tempstore: tempstore }, handlerResults);
|
||||
|
||||
@@ -37,7 +37,7 @@ class Author {
|
||||
imagePath: this.imagePath,
|
||||
relImagePath: this.relImagePath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.updatedAt
|
||||
updatedAt: this.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,19 @@ class Audnexus {
|
||||
})
|
||||
}
|
||||
|
||||
async findAuthorByASIN(asin) {
|
||||
var author = await this.authorRequest(asin)
|
||||
if (!author) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
asin: author.asin,
|
||||
description: author.description,
|
||||
image: author.image,
|
||||
name: author.name
|
||||
}
|
||||
}
|
||||
|
||||
async findAuthorByName(name, maxLevenshtein = 3) {
|
||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||
var asins = await this.authorASINsRequest(name)
|
||||
|
||||
@@ -120,9 +120,6 @@ module.exports.extractCoverArt = extractCoverArt
|
||||
|
||||
//This should convert based on the output file extension as well
|
||||
async function resizeImage(filePath, outputPath, width, height) {
|
||||
var dirname = Path.dirname(outputPath);
|
||||
await fs.ensureDir(dirname);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
var ffmpeg = Ffmpeg(filePath)
|
||||
ffmpeg.addOption(['-vf', `scale=${width || -1}:${height || -1}`])
|
||||
|
||||
@@ -176,17 +176,20 @@ module.exports.downloadFile = async (url, filepath) => {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.sanitizeFilename = (filename, replacement = '') => {
|
||||
module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
if (typeof filename !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
var replacement = ''
|
||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||
var reservedRe = /^\.+$/;
|
||||
var windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
||||
var windowsTrailingRe = /[\. ]+$/;
|
||||
|
||||
var sanitized = filename
|
||||
sanitized = filename
|
||||
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||
.replace(illegalRe, replacement)
|
||||
.replace(controlRe, replacement)
|
||||
.replace(reservedRe, replacement)
|
||||
|
||||
Reference in New Issue
Block a user