mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-28 09:38:56 +01:00
Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata
This commit is contained in:
parent
ae1b94e991
commit
b23f9362ef
@ -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
client/components/cards/PreviewCover.vue
Normal file
82
client/components/cards/PreviewCover.vue
Normal 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>
|
@ -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)) {
|
||||||
|
39
client/components/ui/FileInput.vue
Normal file
39
client/components/ui/FileInput.vue
Normal 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>
|
@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.1.14",
|
"version": "1.1.15",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.1.14",
|
"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": {
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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') {
|
||||||
|
@ -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 }));
|
||||||
|
@ -5,11 +5,12 @@ 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,10 +127,10 @@ 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')
|
||||||
|
@ -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) => {
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
@ -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/')
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user