mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05: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 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'" />
 | 
			
		||||
      <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 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() {
 | 
			
		||||
      return this.audiobook.book || {}
 | 
			
		||||
    },
 | 
			
		||||
    bookLastUpdate() {
 | 
			
		||||
      return this.book.lastUpdate || Date.now()
 | 
			
		||||
    },
 | 
			
		||||
    title() {
 | 
			
		||||
      return this.book.title || 'No Title'
 | 
			
		||||
    },
 | 
			
		||||
@ -76,11 +79,11 @@ export default {
 | 
			
		||||
      return '/book_placeholder.jpg'
 | 
			
		||||
    },
 | 
			
		||||
    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
 | 
			
		||||
      try {
 | 
			
		||||
        var url = new URL(this.cover, document.baseURI)
 | 
			
		||||
        return url.href
 | 
			
		||||
        return url.href + `?token=${this.userToken}&ts=${this.bookLastUpdate}`
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        console.error(err)
 | 
			
		||||
        return ''
 | 
			
		||||
@ -106,6 +109,9 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    authorBottom() {
 | 
			
		||||
      return 0.75 * this.sizeMultiplier
 | 
			
		||||
    },
 | 
			
		||||
    userToken() {
 | 
			
		||||
      return this.$store.getters['user/getToken']
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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>
 | 
			
		||||
  <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="relative">
 | 
			
		||||
        <cards-book-cover :audiobook="audiobook" />
 | 
			
		||||
@ -12,12 +12,15 @@
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex-grow pl-6 pr-2">
 | 
			
		||||
        <form @submit.prevent="submitForm">
 | 
			
		||||
        <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-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
 | 
			
		||||
          </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <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">
 | 
			
		||||
@ -63,6 +66,18 @@
 | 
			
		||||
        </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>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@ -79,12 +94,15 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      processingUpload: false,
 | 
			
		||||
      searchTitle: null,
 | 
			
		||||
      searchAuthor: null,
 | 
			
		||||
      imageUrl: null,
 | 
			
		||||
      coversFound: [],
 | 
			
		||||
      hasSearched: false,
 | 
			
		||||
      showLocalCovers: false
 | 
			
		||||
      showLocalCovers: false,
 | 
			
		||||
      previewUpload: null,
 | 
			
		||||
      selectedFile: null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
@ -112,6 +130,9 @@ export default {
 | 
			
		||||
    otherFiles() {
 | 
			
		||||
      return this.audiobook ? this.audiobook.otherFiles || [] : []
 | 
			
		||||
    },
 | 
			
		||||
    userCanUpload() {
 | 
			
		||||
      return this.$store.getters['user/getUserCanUpload']
 | 
			
		||||
    },
 | 
			
		||||
    localCovers() {
 | 
			
		||||
      return this.otherFiles
 | 
			
		||||
        .filter((f) => f.filetype === 'image')
 | 
			
		||||
@ -123,6 +144,39 @@ export default {
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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() {
 | 
			
		||||
      this.showLocalCovers = false
 | 
			
		||||
      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: {
 | 
			
		||||
    '/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: {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "audiobookshelf-client",
 | 
			
		||||
  "version": "1.1.14",
 | 
			
		||||
  "version": "1.1.15",
 | 
			
		||||
  "description": "Audiobook manager and player",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "audiobookshelf",
 | 
			
		||||
  "version": "1.1.14",
 | 
			
		||||
  "version": "1.1.15",
 | 
			
		||||
  "description": "Self-hosted audiobook server for managing and playing audiobooks.",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,13 @@
 | 
			
		||||
const express = require('express')
 | 
			
		||||
const Path = require('path')
 | 
			
		||||
const fs = require('fs-extra')
 | 
			
		||||
const Logger = require('./Logger')
 | 
			
		||||
const User = require('./objects/User')
 | 
			
		||||
const { isObject } = require('./utils/index')
 | 
			
		||||
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
 | 
			
		||||
const { CoverDestination } = require('./utils/constants')
 | 
			
		||||
 | 
			
		||||
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.scanner = scanner
 | 
			
		||||
    this.auth = auth
 | 
			
		||||
@ -13,6 +16,7 @@ class ApiController {
 | 
			
		||||
    this.downloadManager = downloadManager
 | 
			
		||||
    this.emitter = emitter
 | 
			
		||||
    this.clientEmitter = clientEmitter
 | 
			
		||||
    this.MetadataPath = MetadataPath
 | 
			
		||||
 | 
			
		||||
    this.router = express()
 | 
			
		||||
    this.init()
 | 
			
		||||
@ -30,6 +34,7 @@ class ApiController {
 | 
			
		||||
    this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
 | 
			
		||||
    this.router.delete('/audiobook/:id', this.deleteAudiobook.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.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
 | 
			
		||||
@ -217,6 +222,85 @@ class ApiController {
 | 
			
		||||
    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) {
 | 
			
		||||
    if (!req.user.canUpdate) {
 | 
			
		||||
      Logger.warn('User attempted to update without permission', req.user)
 | 
			
		||||
 | 
			
		||||
@ -38,8 +38,16 @@ class Auth {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async authMiddleware(req, res, next) {
 | 
			
		||||
    var token = null
 | 
			
		||||
 | 
			
		||||
    // 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']
 | 
			
		||||
    const token = authHeader && authHeader.split(' ')[1]
 | 
			
		||||
      token = authHeader && authHeader.split(' ')[1]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (token == null) {
 | 
			
		||||
      Logger.error('Api called without a token', req.path)
 | 
			
		||||
      return res.sendStatus(401)
 | 
			
		||||
 | 
			
		||||
@ -4,13 +4,13 @@ const fs = require('fs-extra')
 | 
			
		||||
const Logger = require('./Logger')
 | 
			
		||||
 | 
			
		||||
class HlsController {
 | 
			
		||||
  constructor(db, scanner, auth, streamManager, emitter, MetadataPath) {
 | 
			
		||||
  constructor(db, scanner, auth, streamManager, emitter, StreamsPath) {
 | 
			
		||||
    this.db = db
 | 
			
		||||
    this.scanner = scanner
 | 
			
		||||
    this.auth = auth
 | 
			
		||||
    this.streamManager = streamManager
 | 
			
		||||
    this.emitter = emitter
 | 
			
		||||
    this.MetadataPath = MetadataPath
 | 
			
		||||
    this.StreamsPath = StreamsPath
 | 
			
		||||
 | 
			
		||||
    this.router = express()
 | 
			
		||||
    this.init()
 | 
			
		||||
@ -28,7 +28,7 @@ class HlsController {
 | 
			
		||||
 | 
			
		||||
  async streamFileRequest(req, res) {
 | 
			
		||||
    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
 | 
			
		||||
    if (streamId === 'test') {
 | 
			
		||||
 | 
			
		||||
@ -35,8 +35,8 @@ class Server {
 | 
			
		||||
    this.streamManager = new StreamManager(this.db, this.MetadataPath)
 | 
			
		||||
    this.rssFeeds = new RssFeeds(this.Port, this.db)
 | 
			
		||||
    this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
 | 
			
		||||
    this.apiController = new ApiController(this.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.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.streamManager.StreamsPath)
 | 
			
		||||
 | 
			
		||||
    this.server = null
 | 
			
		||||
    this.io = null
 | 
			
		||||
@ -110,6 +110,7 @@ class Server {
 | 
			
		||||
 | 
			
		||||
  async init() {
 | 
			
		||||
    Logger.info('[Server] Init')
 | 
			
		||||
    await this.streamManager.ensureStreamsDir()
 | 
			
		||||
    await this.streamManager.removeOrphanStreams()
 | 
			
		||||
    await this.downloadManager.removeOrphanDownloads()
 | 
			
		||||
    await this.db.init()
 | 
			
		||||
@ -189,6 +190,8 @@ class Server {
 | 
			
		||||
      app.use(express.static(this.AudiobookPath))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
 | 
			
		||||
 | 
			
		||||
    app.use(express.static(this.MetadataPath))
 | 
			
		||||
    app.use(express.static(Path.join(global.appRoot, 'static')))
 | 
			
		||||
    app.use(express.urlencoded({ extended: true }));
 | 
			
		||||
 | 
			
		||||
@ -5,11 +5,12 @@ const fs = require('fs-extra')
 | 
			
		||||
const Path = require('path')
 | 
			
		||||
 | 
			
		||||
class StreamManager {
 | 
			
		||||
  constructor(db, STREAM_PATH) {
 | 
			
		||||
  constructor(db, MetadataPath) {
 | 
			
		||||
    this.db = db
 | 
			
		||||
 | 
			
		||||
    this.MetadataPath = MetadataPath
 | 
			
		||||
    this.streams = []
 | 
			
		||||
    this.streamPath = STREAM_PATH
 | 
			
		||||
    this.StreamsPath = Path.join(this.MetadataPath, 'streams')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get audiobooks() {
 | 
			
		||||
@ -25,7 +26,7 @@ class StreamManager {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async openStream(client, audiobook) {
 | 
			
		||||
    var stream = new Stream(this.streamPath, client, audiobook)
 | 
			
		||||
    var stream = new Stream(this.StreamsPath, client, audiobook)
 | 
			
		||||
 | 
			
		||||
    stream.on('closed', () => {
 | 
			
		||||
      this.removeStream(stream)
 | 
			
		||||
@ -44,29 +45,53 @@ class StreamManager {
 | 
			
		||||
    return stream
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ensureStreamsDir() {
 | 
			
		||||
    return fs.ensureDir(this.StreamsPath)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeOrphanStreamFiles(streamId) {
 | 
			
		||||
    try {
 | 
			
		||||
      var streamPath = Path.join(this.streamPath, streamId)
 | 
			
		||||
      return fs.remove(streamPath)
 | 
			
		||||
      var StreamsPath = Path.join(this.StreamsPath, streamId)
 | 
			
		||||
      return fs.remove(StreamsPath)
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      Logger.debug('No orphan stream', streamId)
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async removeOrphanStreams() {
 | 
			
		||||
  async tempCheckStrayStreams() {
 | 
			
		||||
    try {
 | 
			
		||||
      var dirs = await fs.readdir(this.streamPath)
 | 
			
		||||
      var dirs = await fs.readdir(this.MetadataPath)
 | 
			
		||||
      if (!dirs || !dirs.length) return true
 | 
			
		||||
 | 
			
		||||
      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}`)
 | 
			
		||||
        return fs.remove(fullPath)
 | 
			
		||||
      }))
 | 
			
		||||
      return true
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      Logger.debug('No orphan stream', streamId)
 | 
			
		||||
      Logger.debug('No orphan stream', error)
 | 
			
		||||
      return false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -102,10 +127,10 @@ class StreamManager {
 | 
			
		||||
    this.db.updateUserStream(client.user.id, null)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async openTestStream(streamPath, audiobookId) {
 | 
			
		||||
  async openTestStream(StreamsPath, audiobookId) {
 | 
			
		||||
    Logger.info('Open Stream Test Request', 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', () => {
 | 
			
		||||
    //   console.log('Stream closed')
 | 
			
		||||
 | 
			
		||||
@ -288,6 +288,12 @@ class Audiobook {
 | 
			
		||||
    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) {
 | 
			
		||||
    var index = 1
 | 
			
		||||
    this.audioFiles = orderedFileData.map((fileData) => {
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ class Book {
 | 
			
		||||
    this.description = null
 | 
			
		||||
    this.cover = null
 | 
			
		||||
    this.genres = []
 | 
			
		||||
    this.lastUpdate = null
 | 
			
		||||
 | 
			
		||||
    if (book) {
 | 
			
		||||
      this.construct(book)
 | 
			
		||||
@ -45,6 +46,7 @@ class Book {
 | 
			
		||||
    this.description = book.description
 | 
			
		||||
    this.cover = book.cover
 | 
			
		||||
    this.genres = book.genres
 | 
			
		||||
    this.lastUpdate = book.lastUpdate || Date.now()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toJSON() {
 | 
			
		||||
@ -62,7 +64,8 @@ class Book {
 | 
			
		||||
      publisher: this.publisher,
 | 
			
		||||
      description: this.description,
 | 
			
		||||
      cover: this.cover,
 | 
			
		||||
      genres: this.genres
 | 
			
		||||
      genres: this.genres,
 | 
			
		||||
      lastUpdate: this.lastUpdate
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -97,6 +100,7 @@ class Book {
 | 
			
		||||
    this.description = data.description || null
 | 
			
		||||
    this.cover = data.cover || null
 | 
			
		||||
    this.genres = data.genres || []
 | 
			
		||||
    this.lastUpdate = Date.now()
 | 
			
		||||
 | 
			
		||||
    if (data.author) {
 | 
			
		||||
      this.setParseAuthor(this.author)
 | 
			
		||||
@ -145,9 +149,24 @@ class Book {
 | 
			
		||||
        hasUpdates = true
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (hasUpdates) {
 | 
			
		||||
      this.lastUpdate = Date.now()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
  // May be worthwhile checking if these were manually updated and not override manual updates
 | 
			
		||||
  syncPathsUpdated(audiobookData) {
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,12 @@
 | 
			
		||||
const { CoverDestination } = require('../utils/constants')
 | 
			
		||||
 | 
			
		||||
class ServerSettings {
 | 
			
		||||
  constructor(settings) {
 | 
			
		||||
    this.id = 'server-settings'
 | 
			
		||||
    this.autoTagNew = false
 | 
			
		||||
    this.newTagExpireDays = 15
 | 
			
		||||
    this.scannerParseSubtitle = false
 | 
			
		||||
    this.coverDestination = CoverDestination.METADATA
 | 
			
		||||
 | 
			
		||||
    if (settings) {
 | 
			
		||||
      this.construct(settings)
 | 
			
		||||
@ -14,6 +17,7 @@ class ServerSettings {
 | 
			
		||||
    this.autoTagNew = settings.autoTagNew
 | 
			
		||||
    this.newTagExpireDays = settings.newTagExpireDays
 | 
			
		||||
    this.scannerParseSubtitle = settings.scannerParseSubtitle
 | 
			
		||||
    this.coverDestination = settings.coverDestination || CoverDestination.METADATA
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  toJSON() {
 | 
			
		||||
@ -21,7 +25,8 @@ class ServerSettings {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
      autoTagNew: this.autoTagNew,
 | 
			
		||||
      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) + '%'
 | 
			
		||||
      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', {
 | 
			
		||||
        stream: this.id,
 | 
			
		||||
@ -198,7 +198,7 @@ class Stream extends EventEmitter {
 | 
			
		||||
        numSegments: this.numSegments
 | 
			
		||||
      })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      Logger.error('Failed checkign files', error)
 | 
			
		||||
      Logger.error('Failed checking files', error)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -5,3 +5,8 @@ module.exports.ScanResult = {
 | 
			
		||||
  REMOVED: 3,
 | 
			
		||||
  UPTODATE: 4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports.CoverDestination = {
 | 
			
		||||
  METADATA: 0,
 | 
			
		||||
  AUDIOBOOK: 1
 | 
			
		||||
}
 | 
			
		||||
@ -63,3 +63,7 @@ module.exports.getIno = (path) => {
 | 
			
		||||
    return null
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports.isAcceptableCoverMimeType = (mimeType) => {
 | 
			
		||||
  return mimeType && mimeType.startsWith('image/')
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user