Add audiobook uploader

This commit is contained in:
Mark Cooper 2021-09-13 20:18:58 -05:00
parent 6cb253598b
commit 3dfd7ea035
8 changed files with 336 additions and 6 deletions

View File

@ -11,6 +11,10 @@
<controls-global-search />
<div class="flex-grow" />
<nuxt-link v-if="isRootUser" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mr-4">
<span class="material-icons">upload</span>
</nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<span class="material-icons">settings</span>
</nuxt-link>

View File

@ -1,13 +1,13 @@
<template>
<div class="w-40">
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>

View File

@ -1,6 +1,8 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<p class="px-1 text-sm font-semibold">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div>
</template>
@ -10,6 +12,7 @@ export default {
props: {
value: [String, Number],
label: String,
note: String,
type: {
type: String,
default: 'text'

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.1.6",
"version": "1.1.7",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -0,0 +1,258 @@
<template>
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<main class="container mx-auto h-full max-w-screen-lg p-6">
<article class="max-h-full overflow-y-auto relative flex flex-col bg-primary shadow-xl rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="title" label="Title" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="author" label="Author" />
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
</div>
</div>
</div>
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
<div v-if="validImageFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
<div class="flex">
<template v-for="file in validImageFiles">
<div :key="file.name" class="h-28 w-20 bg-bg">
<img :src="file.src" class="h-full w-full object-contain" />
</div>
</template>
</div>
</div>
<div v-if="validAudioFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in validAudioFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
<div v-if="invalidFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in invalidFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
</section>
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
</footer>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</article>
</main>
</div>
</template>
<script>
import Path from 'path'
export default {
data() {
return {
processing: false,
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
acceptedImageFormats: ['image/*'],
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
isDragOver: false,
showUploader: true,
validAudioFiles: [],
validImageFiles: [],
invalidFiles: []
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
hasValidAudioFiles() {
return this.validAudioFiles.length
},
directory() {
if (!this.author || !this.title) return ''
if (this.series) {
return Path.join('/audiobooks', this.author, this.series, this.title)
} else {
return Path.join('/audiobooks', this.author, this.title)
}
}
},
methods: {
reset() {
this.title = ''
this.author = ''
this.series = ''
this.cancel()
},
cancel() {
this.validAudioFiles = []
this.validImageFiles = []
this.invalidFiles = []
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
this.showUploader = true
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
this.filesChanged(_files)
}
},
drop(evt) {
console.log('Dropped event', evt)
this.isDragOver = false
this.preventDefaults(evt)
const files = [...evt.dataTransfer.files]
this.filesChanged(files)
},
dragover(evt) {
console.log('Dragged over', evt)
this.isDragOver = true
this.preventDefaults(evt)
},
dragleave(evt) {
console.log('Dragged leave', evt)
this.isDragOver = false
this.preventDefaults(evt)
},
dragenter(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
},
filesChanged(files) {
console.log('FilesChanged', files)
this.showUploader = false
for (let i = 0; i < files.length; i++) {
var file = files[i]
var ext = Path.extname(file.name)
if (this.acceptedAudioFormats.includes(ext)) {
this.validAudioFiles.push(file)
} else if (file.type.startsWith('image/')) {
file.src = URL.createObjectURL(file)
this.validImageFiles.push(file)
} else {
this.invalidFiles.push(file)
}
}
},
clickSelectAudioFiles() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
submit() {
if (!this.title || !this.author) {
this.$toast.error('Must enter a title and author')
return
}
this.processing = true
var form = new FormData()
form.set('title', this.title)
form.set('author', this.author)
form.set('series', this.series)
var index = 0
var files = this.validAudioFiles.concat(this.validImageFiles)
files.forEach((file) => {
form.set(`${index++}`, file)
})
this.$axios
.$post('/upload', form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
}
this.processing = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
this.processing = false
})
}
},
mounted() {}
}
</script>

31
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "0.9.76-beta",
"version": "1.1.6",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -165,6 +165,14 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk="
},
"busboy": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz",
"integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==",
"requires": {
"dicer": "0.3.0"
}
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
@ -291,6 +299,14 @@
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
},
"dicer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
"integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==",
"requires": {
"streamsearch": "0.1.2"
}
},
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -406,6 +422,14 @@
"vary": "~1.1.2"
}
},
"express-fileupload": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.2.1.tgz",
"integrity": "sha512-fWPNAkBj+Azt9Itmcz/Reqdg3LeBfaXptDEev2JM8bCC0yDptglCnlizhf0YZauyU5X/g6v7v4Xxqhg8tmEfEA==",
"requires": {
"busboy": "^0.3.1"
}
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@ -1017,6 +1041,11 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
"integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
},
"string-indexes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.1.6",
"version": "1.1.7",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
@ -14,6 +14,7 @@
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"ip": "^1.1.5",

View File

@ -3,6 +3,7 @@ const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
@ -132,6 +133,7 @@ class Server {
this.server = http.createServer(app)
app.use(this.auth.cors)
app.use(fileUpload())
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
@ -155,6 +157,39 @@ class Server {
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router)
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author
var series = req.body.series
if (!files.length || !title || !author) {
return res.json({
error: 'Invalid post data received'
})
}
var outputDirectory = ''
if (series && series.length && series !== 'null') {
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
} else {
outputDirectory = Path.join(this.AudiobookPath, author, title)
}
await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) {
var file = files[i]
var path = Path.join(outputDirectory, file.name)
await file.mv(path).catch((error) => {
Logger.error('Failed to move file', path, error)
})
}
res.sendStatus(200)
})
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => {