Compare commits

...

10 Commits

Author SHA1 Message Date
Mark Cooper 6cb418a871 Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata 2021-09-21 20:57:33 -05:00
Mark Cooper d234fdea11 Fix player track tooltip overflowing page 2021-09-21 17:28:43 -05:00
Mark Cooper 13ac5f1d2a Fix package.json script 2021-09-21 16:55:32 -05:00
Mark Cooper f4d6e65380 Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
Mark Cooper baccbaf82a Remove production from prod script 2021-09-19 20:38:24 -05:00
Mark Cooper d6969e0b85 Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
Mark Cooper b3ad9c95ce Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
Mark Cooper 2f6417dec2 Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
Mark Cooper f54d48270e Update regex for volume scanner 2021-09-18 13:26:02 -05:00
Mark Cooper 57321084af Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
39 changed files with 602 additions and 84 deletions
+50 -9
View File
@@ -12,9 +12,6 @@
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters"> <div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span> <span class="material-icons text-3xl">format_list_bulleted</span>
</div> </div>
<div v-else class="flex items-center justify-center text-gray-500">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div> </div>
<div class="absolute right-32 top-0 bottom-0"> <div class="absolute right-32 top-0 bottom-0">
@@ -56,10 +53,17 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" /> <div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" /> <div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div> </div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp --> <!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none"> <div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p> <p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center"> <div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
@@ -68,7 +72,7 @@
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" /> <audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" /> <modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
@@ -100,7 +104,9 @@ export default {
totalDuration: 0, totalDuration: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false, seekLoading: false,
showChaptersModal: false showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16 // Track is 16px from edge
} }
}, },
computed: { computed: {
@@ -109,6 +115,18 @@ export default {
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
} }
}, },
methods: { methods: {
@@ -172,10 +190,29 @@ export default {
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1 this.$refs.hoverTimestamp.style.opacity = 1
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px' var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
} }
if (this.$refs.hoverTimestampText) { if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time) var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
} }
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1 this.$refs.trackCursor.style.opacity = 1
@@ -186,6 +223,9 @@ export default {
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0 this.$refs.hoverTimestamp.style.opacity = 0
} }
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0 this.$refs.trackCursor.style.opacity = 0
} }
@@ -289,7 +329,6 @@ export default {
end: end + offset end: end + offset
}) })
} }
return ranges return ranges
}, },
getLastBufferedTime() { getLastBufferedTime() {
@@ -334,6 +373,8 @@ export default {
this.updateTimestamp() this.updateTimestamp()
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth) var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
+7 -3
View File
@@ -10,8 +10,11 @@
</div> </div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12"> <div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p> <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn> <div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div> </div>
<div v-else class="w-full flex flex-col items-center"> <div v-else class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks"> <template v-for="(shelf, index) in groupedBooks">
@@ -43,7 +46,8 @@ export default {
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3, selectedSizeIndex: 3,
rowPaddingX: 40, rowPaddingX: 40,
keywordFilterTimeout: null keywordFilterTimeout: null,
scannerParseSubtitle: false
} }
}, },
watch: { watch: {
@@ -42,7 +42,6 @@ export default {
this.saveSettings() this.saveSettings()
}, },
saveSettings() { saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings) this.$store.dispatch('user/updateUserSettings', this.settings)
}, },
init() { init() {
+15 -6
View File
@@ -1,14 +1,14 @@
<template> <template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4"> <div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div class="absolute -top-16 left-4"> <nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" /> <cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div> </nuxt-link>
<div class="flex items-center pl-24"> <div class="flex items-center pl-24">
<div> <div>
<h1> <nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span> {{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1> </nuxt-link>
<p class="text-gray-400 text-sm">by {{ author }}</p> <p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span> <span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
@@ -66,6 +66,15 @@ export default {
} }
}, },
methods: { methods: {
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push('/')
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() { audioPlayerMounted() {
this.audioPlayerReady = true this.audioPlayerReady = true
if (this.stream) { if (this.stream) {
+2 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="relative"> <div class="relative">
<!-- New Book Flag --> <!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl"> <div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center"> <div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p> <p class="text-center text-sm">New</p>
</div> </div>
@@ -65,7 +65,7 @@ export default {
}, },
computed: { computed: {
isNew() { isNew() {
return this.tags.includes('new') return this.tags.includes('New')
}, },
tags() { tags() {
return this.audiobook.tags || [] return this.audiobook.tags || []
+9 -3
View File
@@ -4,7 +4,7 @@
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full"> <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" /> <div class="w-full h-full z-0" ref="coverBg" />
</div> </div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> <img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div> </div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }"> <div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
@@ -53,6 +53,9 @@ export default {
book() { book() {
return this.audiobook.book || {} return this.audiobook.book || {}
}, },
bookLastUpdate() {
return this.book.lastUpdate || Date.now()
},
title() { title() {
return this.book.title || 'No Title' return this.book.title || 'No Title'
}, },
@@ -76,11 +79,11 @@ export default {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
fullCoverUrl() { fullCoverUrl() {
if (!this.cover || this.cover === this.placeholderUrl) return '' if (!this.cover || this.cover === this.placeholderUrl) return this.placeholderUrl
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
try { try {
var url = new URL(this.cover, document.baseURI) var url = new URL(this.cover, document.baseURI)
return url.href return url.href + `?token=${this.userToken}&ts=${this.bookLastUpdate}`
} catch (err) { } catch (err) {
console.error(err) console.error(err)
return '' return ''
@@ -106,6 +109,9 @@ export default {
}, },
authorBottom() { authorBottom() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
} }
}, },
methods: { methods: {
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
src: String,
width: {
type: Number,
default: 120
}
},
data() {
return {
imageFailed: false,
showCoverBg: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
cover() {
return this.src
},
sizeMultiplier() {
return this.width / 120
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
this.$refs.coverBg.style.backgroundSize = 'cover'
this.$refs.coverBg.style.backgroundPosition = 'center'
this.$refs.coverBg.style.opacity = 0.25
this.$refs.coverBg.style.filter = 'blur(1px)'
}
},
imageLoaded() {
if (this.$refs.cover) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
}
},
mounted() {}
}
</script>
@@ -86,6 +86,11 @@ export default {
text: 'Authors', text: 'Authors',
value: 'authors', value: 'authors',
sublist: true sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
} }
] ]
} }
@@ -132,6 +137,9 @@ export default {
authors() { authors() {
return this.$store.getters['audiobooks/getUniqueAuthors'] return this.$store.getters['audiobooks/getUniqueAuthors']
}, },
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() { sublistItems() {
return (this[this.sublist] || []).map((item) => { return (this[this.sublist] || []).map((item) => {
return { return {
@@ -74,10 +74,10 @@ export default {
this.showMenu = false this.showMenu = false
}, },
leftArrowClick() { leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4) this.rateIndex = Math.max(0, this.rateIndex - 1)
}, },
rightArrowClick() { rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4) this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
} }
}, },
mounted() {} mounted() {}
+8 -1
View File
@@ -2,7 +2,7 @@
<modals-modal v-model="show" :width="500" :height="'unset'"> <modals-modal v-model="show" :width="500" :height="'unset'">
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px"> <div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)"> <div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }} {{ chap.title }}
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
@@ -19,6 +19,10 @@ export default {
chapters: { chapters: {
type: Array, type: Array,
default: () => [] default: () => []
},
currentChapter: {
type: Object,
default: () => null
} }
}, },
data() { data() {
@@ -32,6 +36,9 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
} }
}, },
methods: { methods: {
+60 -6
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex"> <div class="flex">
<div class="relative"> <div class="relative">
<cards-book-cover :audiobook="audiobook" /> <cards-book-cover :audiobook="audiobook" />
@@ -12,12 +12,15 @@
</div> </div>
</div> </div>
<div class="flex-grow pl-6 pr-2"> <div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm"> <div class="flex items-center">
<div class="flex items-center"> <div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" /> <ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn> <ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
</div> </form>
</form> </div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary"> <div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2"> <div class="flex items-center justify-center py-2">
@@ -63,6 +66,18 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
<p class="text-lg">Preview Cover</p>
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
<div class="flex justify-center py-4">
<cards-preview-cover :src="previewUpload" :width="240" />
</div>
<div class="absolute bottom-0 right-0 flex py-4 px-5">
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
</div>
</div>
</div> </div>
</template> </template>
@@ -79,12 +94,15 @@ export default {
}, },
data() { data() {
return { return {
processingUpload: false,
searchTitle: null, searchTitle: null,
searchAuthor: null, searchAuthor: null,
imageUrl: null, imageUrl: null,
coversFound: [], coversFound: [],
hasSearched: false, hasSearched: false,
showLocalCovers: false showLocalCovers: false,
previewUpload: null,
selectedFile: null
} }
}, },
watch: { watch: {
@@ -112,6 +130,9 @@ export default {
otherFiles() { otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : [] return this.audiobook ? this.audiobook.otherFiles || [] : []
}, },
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
localCovers() { localCovers() {
return this.otherFiles return this.otherFiles
.filter((f) => f.filetype === 'image') .filter((f) => f.filetype === 'image')
@@ -123,6 +144,39 @@ export default {
} }
}, },
methods: { methods: {
submitCoverUpload() {
this.processingUpload = true
var form = new FormData()
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview()
}
this.processingUpload = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
this.processingUpload = false
})
},
resetCoverPreview() {
if (this.$refs.fileInput) {
this.$refs.fileInput.reset()
}
this.previewUpload = null
this.selectedFile = null
},
fileUploadSelected(file) {
this.previewUpload = URL.createObjectURL(file)
this.selectedFile = file
},
init() { init() {
this.showLocalCovers = false this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) { if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
+11 -1
View File
@@ -1,5 +1,14 @@
<template> <template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click"> <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> --> <!-- <span class="material-icons animate-spin">refresh</span> -->
@@ -13,6 +22,7 @@
<script> <script>
export default { export default {
props: { props: {
to: String,
color: { color: {
type: String, type: String,
default: 'primary' default: 'primary'
+39
View File
@@ -0,0 +1,39 @@
<template>
<div>
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
</div>
</template>
<script>
export default {
data() {
return {
inputAccept: 'image/*'
}
},
computed: {},
methods: {
reset() {
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
clickUpload() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
var file = _files[0]
console.log('File', file)
this.$emit('change', file)
}
}
},
mounted() {}
}
</script>
-1
View File
@@ -113,7 +113,6 @@ export default {
this.currentSearch = null this.currentSearch = null
}, },
clickedOption(e, item) { clickedOption(e, item) {
var newValue = this.input === item ? null : item
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.input = this.textInput ? this.textInput.trim() : null this.input = this.textInput ? this.textInput.trim() : null
+1 -1
View File
@@ -180,7 +180,7 @@ export default {
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim() var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => { var matchesItem = this.items.find((i) => {
return i === cleaned return i === cleaned
}) })
+3 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave"> <div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot /> <slot />
</div> </div>
</template> </template>
@@ -51,8 +51,9 @@ export default {
createTooltip() { createTooltip() {
if (!this.$refs.box) return if (!this.$refs.box) return
var tooltip = document.createElement('div') var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg' tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.75)'
tooltip.innerHTML = this.text tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip) this.setTooltipPosition(tooltip)
+2 -1
View File
@@ -71,7 +71,8 @@ module.exports = {
proxy: { proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }, '/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } } '/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
}, },
io: { io: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.0.0", "version": "1.1.13",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.1.13", "version": "1.1.15",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -9,7 +9,7 @@
"start": "nuxt start", "start": "nuxt start",
"generate": "nuxt generate" "generate": "nuxt generate"
}, },
"author": "", "author": "advplyr",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+6 -1
View File
@@ -49,7 +49,12 @@
<div class="flex-grow" /> <div class="flex-grow" />
<div class="w-40 flex flex-col"> <div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn> <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
<div class="w-full">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
</div> </div>
</div> </div>
</div> </div>
+12 -2
View File
@@ -16,12 +16,12 @@ export const getters = {
getAudiobook: (state) => id => { getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id) return state.audiobooks.find(ab => ab.id === id)
}, },
getFiltered: (state, getters, rootState) => () => { getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors'] var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = decode(filterBy.replace(`${group}.`, '')) var filter = decode(filterBy.replace(`${group}.`, ''))
@@ -29,6 +29,16 @@ export const getters = {
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter) else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
}
} }
if (state.keywordFilter) { if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator'] const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
+2
View File
@@ -44,6 +44,8 @@ export const actions = {
var updatePayload = { var updatePayload = {
...payload ...payload
} }
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => { return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) { if (result.success) {
commit('setSettings', result.settings) commit('setSettings', result.settings)
+35 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.7", "version": "1.1.13",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -138,6 +138,11 @@
"is-primitive": "^3.0.1" "is-primitive": "^3.0.1"
} }
}, },
"array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="
},
"array-flatten": { "array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -288,6 +293,17 @@
"mimic-response": "^1.0.0" "mimic-response": "^1.0.0"
} }
}, },
"command-line-args": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
"integrity": "sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==",
"requires": {
"array-back": "^3.1.0",
"find-replace": "^3.0.0",
"lodash.camelcase": "^4.3.0",
"typical": "^4.0.0"
}
},
"component-emitter": { "component-emitter": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
@@ -566,6 +582,14 @@
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
} }
}, },
"find-replace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
"requires": {
"array-back": "^3.0.1"
}
},
"fluent-ffmpeg": { "fluent-ffmpeg": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz", "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
@@ -830,6 +854,11 @@
"got": "11.3.x" "got": "11.3.x"
} }
}, },
"lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
},
"lodash.defaults": { "lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1339,6 +1368,11 @@
"mime-types": "~2.1.24" "mime-types": "~2.1.24"
} }
}, },
"typical": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="
},
"universalify": { "universalify": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+5 -2
View File
@@ -1,11 +1,13 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.13", "version": "1.1.15",
"description": "Self-hosted audiobook server for managing and playing audiobooks.", "description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
"start": "node index.js" "start": "node index.js",
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js"
}, },
"author": "advplyr", "author": "advplyr",
"license": "ISC", "license": "ISC",
@@ -13,6 +15,7 @@
"archiver": "^5.3.0", "archiver": "^5.3.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
+30
View File
@@ -0,0 +1,30 @@
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 }
]
const commandLineArgs = require('command-line-args')
const options = commandLineArgs(optionDefinitions)
const Path = require('path')
process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
process.env.NODE_ENV = 'production'
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 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')
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start()
+15
View File
@@ -72,6 +72,21 @@ Built to run in Docker for now (also on Unraid server Community Apps)
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
``` ```
## Running on your local
```bash
git clone https://github.com/advplyr/audiobookshelf.git
cd audiobookshelf
# All paths default to root directory. Config path is the database.
# Directories will be created if they don't exist
# Paths are relative to the root directory, so "../Audiobooks" would be a valid path
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
# You only need to use `npm run prod` the first time, after that use `npm run start`
npm run start -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
```
## Contributing ## Contributing
Feel free to help out Feel free to help out
+86 -2
View File
@@ -1,10 +1,13 @@
const express = require('express') const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger') const Logger = require('./Logger')
const User = require('./objects/User') const User = require('./objects/User')
const { isObject } = require('./utils/index') const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
const { CoverDestination } = require('./utils/constants')
class ApiController { class ApiController {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) { constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
this.db = db this.db = db
this.scanner = scanner this.scanner = scanner
this.auth = auth this.auth = auth
@@ -13,6 +16,7 @@ class ApiController {
this.downloadManager = downloadManager this.downloadManager = downloadManager
this.emitter = emitter this.emitter = emitter
this.clientEmitter = clientEmitter this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.router = express() this.router = express()
this.init() this.init()
@@ -30,6 +34,7 @@ class ApiController {
this.router.get('/audiobook/:id', this.getAudiobook.bind(this)) this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this)) this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this)) this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this)) this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
@@ -217,6 +222,85 @@ class ApiController {
res.json(audiobook.toJSON()) res.json(audiobook.toJSON())
} }
async uploadAudiobookCover(req, res) {
if (!req.user.canUpload || !req.user.canUpdate) {
Logger.warn('User attempted to upload a cover without permission', req.user)
return res.sendStatus(403)
}
if (!req.files || !req.files.cover) {
return res.status(400).send('No files were uploaded')
}
var audiobookId = req.params.id
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var coverFile = req.files.cover
var mimeType = coverFile.mimetype
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
if (!isAcceptableCoverMimeType(mimeType)) {
return res.status(400).send('Invalid image file type: ' + mimeType)
}
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
var coverDirpath = audiobook.fullPath
var coverRelDirpath = Path.join('/local', audiobook.path)
if (coverDestination === CoverDestination.METADATA) {
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
await fs.ensureDir(coverDirpath)
} else {
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
}
var coverFilename = `cover${extname}`
var coverFullPath = Path.join(coverDirpath, coverFilename)
var coverPath = Path.join(coverRelDirpath, coverFilename)
// If current cover is a metadata cover and does not match replacement, then remove it
var currentBookCover = audiobook.book.cover
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
if (currentBookCover !== coverPath) {
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
// Metadata path may have changed, check if exists first
var exists = await fs.pathExists(oldFullBookCoverPath)
if (exists) {
try {
await fs.remove(oldFullBookCoverPath)
} catch (error) {
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
}
}
}
}
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('Failed to move cover file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move cover into destination')
}
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json({
success: true,
cover: coverPath
})
}
async updateAudiobook(req, res) { async updateAudiobook(req, res) {
if (!req.user.canUpdate) { if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user) Logger.warn('User attempted to update without permission', req.user)
+10 -2
View File
@@ -38,8 +38,16 @@ class Auth {
} }
async authMiddleware(req, res, next) { async authMiddleware(req, res, next) {
const authHeader = req.headers['authorization'] var token = null
const token = authHeader && authHeader.split(' ')[1]
// If using a get request, the token can be passed as a query string
if (req.method === 'GET' && req.query && req.query.token) {
token = req.query.token
} else {
const authHeader = req.headers['authorization']
token = authHeader && authHeader.split(' ')[1]
}
if (token == null) { if (token == null) {
Logger.error('Api called without a token', req.path) Logger.error('Api called without a token', req.path)
return res.sendStatus(401) return res.sendStatus(401)
+3 -3
View File
@@ -4,13 +4,13 @@ const fs = require('fs-extra')
const Logger = require('./Logger') const Logger = require('./Logger')
class HlsController { class HlsController {
constructor(db, scanner, auth, streamManager, emitter, MetadataPath) { constructor(db, scanner, auth, streamManager, emitter, StreamsPath) {
this.db = db this.db = db
this.scanner = scanner this.scanner = scanner
this.auth = auth this.auth = auth
this.streamManager = streamManager this.streamManager = streamManager
this.emitter = emitter this.emitter = emitter
this.MetadataPath = MetadataPath this.StreamsPath = StreamsPath
this.router = express() this.router = express()
this.init() this.init()
@@ -28,7 +28,7 @@ class HlsController {
async streamFileRequest(req, res) { async streamFileRequest(req, res) {
var streamId = req.params.stream var streamId = req.params.stream
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file) var fullFilePath = Path.join(this.StreamsPath, streamId, req.params.file)
// development test stream - ignore // development test stream - ignore
if (streamId === 'test') { if (streamId === 'test') {
+5 -2
View File
@@ -35,8 +35,8 @@ class Server {
this.streamManager = new StreamManager(this.db, this.MetadataPath) this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db) this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this)) this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, 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.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath) this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
this.server = null this.server = null
this.io = null this.io = null
@@ -110,6 +110,7 @@ class Server {
async init() { async init() {
Logger.info('[Server] Init') Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir()
await this.streamManager.removeOrphanStreams() await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads() await this.downloadManager.removeOrphanDownloads()
await this.db.init() await this.db.init()
@@ -189,6 +190,8 @@ class Server {
app.use(express.static(this.AudiobookPath)) 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(this.MetadataPath))
app.use(express.static(Path.join(global.appRoot, 'static'))) app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
+46 -21
View File
@@ -1,15 +1,16 @@
const Stream = require('./objects/Stream') const Stream = require('./objects/Stream')
const StreamTest = require('./test/StreamTest') // const StreamTest = require('./test/StreamTest')
const Logger = require('./Logger') const Logger = require('./Logger')
const fs = require('fs-extra') const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
class StreamManager { class StreamManager {
constructor(db, STREAM_PATH) { constructor(db, MetadataPath) {
this.db = db this.db = db
this.MetadataPath = MetadataPath
this.streams = [] this.streams = []
this.streamPath = STREAM_PATH this.StreamsPath = Path.join(this.MetadataPath, 'streams')
} }
get audiobooks() { get audiobooks() {
@@ -25,7 +26,7 @@ class StreamManager {
} }
async openStream(client, audiobook) { async openStream(client, audiobook) {
var stream = new Stream(this.streamPath, client, audiobook) var stream = new Stream(this.StreamsPath, client, audiobook)
stream.on('closed', () => { stream.on('closed', () => {
this.removeStream(stream) this.removeStream(stream)
@@ -44,29 +45,53 @@ class StreamManager {
return stream return stream
} }
ensureStreamsDir() {
return fs.ensureDir(this.StreamsPath)
}
removeOrphanStreamFiles(streamId) { removeOrphanStreamFiles(streamId) {
try { try {
var streamPath = Path.join(this.streamPath, streamId) var StreamsPath = Path.join(this.StreamsPath, streamId)
return fs.remove(streamPath) return fs.remove(StreamsPath)
} catch (error) { } catch (error) {
Logger.debug('No orphan stream', streamId) Logger.debug('No orphan stream', streamId)
return false return false
} }
} }
async removeOrphanStreams() { async tempCheckStrayStreams() {
try { try {
var dirs = await fs.readdir(this.streamPath) var dirs = await fs.readdir(this.MetadataPath)
if (!dirs || !dirs.length) return true if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => { await Promise.all(dirs.map(async (dirname) => {
var fullPath = Path.join(this.streamPath, dirname) if (dirname !== 'streams' && dirname !== 'books') {
var fullPath = Path.join(this.MetadataPath, dirname)
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
return fs.remove(fullPath)
}
}))
return true
} catch (error) {
Logger.debug('No old orphan streams', error)
return false
}
}
async removeOrphanStreams() {
await this.tempCheckStrayStreams()
try {
var dirs = await fs.readdir(this.StreamsPath)
if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => {
var fullPath = Path.join(this.StreamsPath, dirname)
Logger.info(`Removing Orphan Stream ${dirname}`) Logger.info(`Removing Orphan Stream ${dirname}`)
return fs.remove(fullPath) return fs.remove(fullPath)
})) }))
return true return true
} catch (error) { } catch (error) {
Logger.debug('No orphan stream', streamId) Logger.debug('No orphan stream', error)
return false return false
} }
} }
@@ -102,21 +127,21 @@ class StreamManager {
this.db.updateUserStream(client.user.id, null) this.db.updateUserStream(client.user.id, null)
} }
async openTestStream(streamPath, audiobookId) { async openTestStream(StreamsPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId) Logger.info('Open Stream Test Request', audiobookId)
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId) // var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
var stream = new StreamTest(streamPath, audiobook) // var stream = new StreamTest(StreamsPath, audiobook)
stream.on('closed', () => { // stream.on('closed', () => {
console.log('Stream closed') // console.log('Stream closed')
}) // })
var playlistUri = await stream.generatePlaylist() // var playlistUri = await stream.generatePlaylist()
stream.start() // stream.start()
Logger.info('Stream Playlist', playlistUri) // Logger.info('Stream Playlist', playlistUri)
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id) // Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
return playlistUri // return playlistUri
} }
streamUpdate(socket, { currentTime, streamId }) { streamUpdate(socket, { currentTime, streamId }) {
+6
View File
@@ -288,6 +288,12 @@ class Audiobook {
return hasUpdates return hasUpdates
} }
// Cover Url may be the same, this ensures the lastUpdate is updated
updateBookCover(cover) {
if (!this.book) return false
return this.book.updateCover(cover)
}
updateAudioTracks(orderedFileData) { updateAudioTracks(orderedFileData) {
var index = 1 var index = 1
this.audioFiles = orderedFileData.map((fileData) => { this.audioFiles = orderedFileData.map((fileData) => {
+20 -1
View File
@@ -18,6 +18,7 @@ class Book {
this.description = null this.description = null
this.cover = null this.cover = null
this.genres = [] this.genres = []
this.lastUpdate = null
if (book) { if (book) {
this.construct(book) this.construct(book)
@@ -45,6 +46,7 @@ class Book {
this.description = book.description this.description = book.description
this.cover = book.cover this.cover = book.cover
this.genres = book.genres this.genres = book.genres
this.lastUpdate = book.lastUpdate || Date.now()
} }
toJSON() { toJSON() {
@@ -62,7 +64,8 @@ class Book {
publisher: this.publisher, publisher: this.publisher,
description: this.description, description: this.description,
cover: this.cover, cover: this.cover,
genres: this.genres genres: this.genres,
lastUpdate: this.lastUpdate
} }
} }
@@ -97,6 +100,7 @@ class Book {
this.description = data.description || null this.description = data.description || null
this.cover = data.cover || null this.cover = data.cover || null
this.genres = data.genres || [] this.genres = data.genres || []
this.lastUpdate = Date.now()
if (data.author) { if (data.author) {
this.setParseAuthor(this.author) this.setParseAuthor(this.author)
@@ -145,9 +149,24 @@ class Book {
hasUpdates = true hasUpdates = true
} }
} }
if (hasUpdates) {
this.lastUpdate = Date.now()
}
return hasUpdates return hasUpdates
} }
updateCover(cover) {
if (!cover) return false
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
cover = Path.normalize(cover)
}
this.cover = cover
this.lastUpdate = Date.now()
return true
}
// If audiobook directory path was changed, check and update properties set from dirnames // If audiobook directory path was changed, check and update properties set from dirnames
// May be worthwhile checking if these were manually updated and not override manual updates // May be worthwhile checking if these were manually updated and not override manual updates
syncPathsUpdated(audiobookData) { syncPathsUpdated(audiobookData) {
+6 -1
View File
@@ -1,9 +1,12 @@
const { CoverDestination } = require('../utils/constants')
class ServerSettings { class ServerSettings {
constructor(settings) { constructor(settings) {
this.id = 'server-settings' this.id = 'server-settings'
this.autoTagNew = false this.autoTagNew = false
this.newTagExpireDays = 15 this.newTagExpireDays = 15
this.scannerParseSubtitle = false this.scannerParseSubtitle = false
this.coverDestination = CoverDestination.METADATA
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -14,6 +17,7 @@ class ServerSettings {
this.autoTagNew = settings.autoTagNew this.autoTagNew = settings.autoTagNew
this.newTagExpireDays = settings.newTagExpireDays this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle this.scannerParseSubtitle = settings.scannerParseSubtitle
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
} }
toJSON() { toJSON() {
@@ -21,7 +25,8 @@ class ServerSettings {
id: this.id, id: this.id,
autoTagNew: this.autoTagNew, autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays, newTagExpireDays: this.newTagExpireDays,
scannerParseSubtitle: this.scannerParseSubtitle scannerParseSubtitle: this.scannerParseSubtitle,
coverDestination: this.coverDestination
} }
} }
+2 -2
View File
@@ -189,7 +189,7 @@ class Stream extends EventEmitter {
var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%' var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`) Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
Logger.info('[STREAM-CHECK] Chunks', chunks.join(', ')) Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))
this.socket.emit('stream_progress', { this.socket.emit('stream_progress', {
stream: this.id, stream: this.id,
@@ -198,7 +198,7 @@ class Stream extends EventEmitter {
numSegments: this.numSegments numSegments: this.numSegments
}) })
} catch (error) { } catch (error) {
Logger.error('Failed checkign files', error) Logger.error('Failed checking files', error)
} }
} }
+5
View File
@@ -4,4 +4,9 @@ module.exports.ScanResult = {
UPDATED: 2, UPDATED: 2,
REMOVED: 3, REMOVED: 3,
UPTODATE: 4 UPTODATE: 4
}
module.exports.CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
} }
+1 -1
View File
@@ -1,6 +1,6 @@
const Ffmpeg = require('fluent-ffmpeg') const Ffmpeg = require('fluent-ffmpeg')
if (process.env.NODE_ENV !== 'production') { if (process.env.FFMPEG_PATH) {
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH) Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
} }
+4
View File
@@ -62,4 +62,8 @@ module.exports.getIno = (path) => {
Logger.error('[Utils] Failed to get ino for path', path, err) Logger.error('[Utils] Failed to get ino for path', path, err)
return null return null
}) })
}
module.exports.isAcceptableCoverMimeType = (mimeType) => {
return mimeType && mimeType.startsWith('image/')
} }
+1 -1
View File
@@ -169,7 +169,7 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
*/ */
var volumeNumber = null var volumeNumber = null
if (series) { if (series) {
var volumeMatch = title.match(/(-(?: ?))?\b((?:Book|Vol.?|Volume) \b(\d{1,3}))((?: ?)-)?/i) var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) { if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
volumeNumber = volumeMatch[3] volumeNumber = volumeMatch[3]
var replaceChunk = volumeMatch[2] var replaceChunk = volumeMatch[2]