mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-26 16:48:53 +01:00
Add:Support multiple book upload #248
This commit is contained in:
parent
907790fe92
commit
aa675422a9
120
client/components/cards/BookUploadCard.vue
Normal file
120
client/components/cards/BookUploadCard.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="relative w-full py-4 px-6 border border-white border-opacity-10 shadow-lg rounded-md my-6">
|
||||
<div class="absolute -top-3 -left-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full">
|
||||
<p class="text-base text-white text-opacity-80 font-mono">#{{ book.index }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!uploadSuccess && !uploadFailed">
|
||||
<widgets-alert v-if="error" type="error">
|
||||
<p class="text-base">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.title" :disabled="processing" label="Title" @input="titleUpdated" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.author" :disabled="processing" label="Author" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 -mx-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="bookData.series" :disabled="processing" 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: 38px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-uploaded-files-table :files="book.bookFiles" title="Book Files" class="mt-8" />
|
||||
<tables-uploaded-files-table v-if="book.otherFiles.length" title="Other Files" :files="book.otherFiles" />
|
||||
<tables-uploaded-files-table v-if="book.ignoredFiles.length" title="Ignored Files" :files="book.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">Successfully Uploaded!</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="uploadFailed" type="error">
|
||||
<p class="text-base">Failed to upload</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isUploading" 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
book: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bookData: {
|
||||
title: '',
|
||||
author: '',
|
||||
series: ''
|
||||
},
|
||||
error: '',
|
||||
isUploading: false,
|
||||
uploadFailed: false,
|
||||
uploadSuccess: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
directory() {
|
||||
if (!this.bookData.title) return ''
|
||||
if (this.bookData.series && this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.series, this.bookData.title)
|
||||
} else if (this.bookData.author) {
|
||||
return Path.join(this.bookData.author, this.bookData.title)
|
||||
} else {
|
||||
return this.bookData.title
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUploadStatus(status) {
|
||||
this.isUploading = status === 'uploading'
|
||||
this.uploadFailed = status === 'failed'
|
||||
this.uploadSuccess = status === 'success'
|
||||
},
|
||||
titleUpdated() {
|
||||
this.error = ''
|
||||
},
|
||||
getData() {
|
||||
if (!this.bookData.title) {
|
||||
this.error = 'Must have a title'
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
var files = this.book.bookFiles.concat(this.book.otherFiles)
|
||||
return {
|
||||
index: this.book.index,
|
||||
...this.bookData,
|
||||
files
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.book) {
|
||||
this.bookData.title = this.book.title
|
||||
this.bookData.author = this.book.author
|
||||
this.bookData.series = this.book.series
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
60
client/components/tables/UploadedFilesTable.vue
Normal file
60
client/components/tables/UploadedFilesTable.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="w-full my-4" @mousedown.prevent @mouseup.prevent>
|
||||
<div class="w-full bg-primary px-6 py-1 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">{{ title }}</p>
|
||||
<span class="bg-black-400 rounded-xl py-0.5 px-2 text-sm font-mono">{{ files.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<div class="cursor-pointer h-9 w-9 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expand ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="expand">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Type</th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ file.name }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.size) }}
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ file.filetype }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: String,
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expand: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expand = !this.expand
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
33
client/components/widgets/Alert.vue
Normal file
33
client/components/widgets/Alert.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="w-full bg-opacity-5 border border-opacity-60 rounded-lg flex items-center relative py-4 pl-16" :class="wrapperClass">
|
||||
<div class="absolute top-0 left-4 h-full flex items-center">
|
||||
<span class="material-icons-outlined text-2xl">{{ icon }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'error'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.type === 'error' || this.type === 'warning') return 'report'
|
||||
return 'info'
|
||||
},
|
||||
wrapperClass() {
|
||||
return `bg-${this.type} border-${this.type} text-${this.type}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
230
client/mixins/uploadHelpers.js
Normal file
230
client/mixins/uploadHelpers.js
Normal file
@ -0,0 +1,230 @@
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
uploadHelpers: {
|
||||
getBooksFromDrop: this.getBooksFromDataTransferItems,
|
||||
getBooksFromPicker: this.getBooksFromFileList
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkFileType(filename) {
|
||||
var ext = Path.extname(filename)
|
||||
if (!ext) return false
|
||||
if (ext.startsWith('.')) ext = ext.slice(1)
|
||||
ext = ext.toLowerCase()
|
||||
|
||||
for (const filetype in this.$constants.SupportedFileTypes) {
|
||||
if (this.$constants.SupportedFileTypes[filetype].includes(ext)) {
|
||||
return filetype
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
filterAudiobookFiles(files) {
|
||||
var validBookFiles = []
|
||||
var validOtherFiles = []
|
||||
var ignoredFiles = []
|
||||
files.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (filetype === 'audio' || filetype === 'ebook') validBookFiles.push(file)
|
||||
else validOtherFiles.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
bookFiles: validBookFiles,
|
||||
otherFiles: validOtherFiles,
|
||||
ignoredFiles
|
||||
}
|
||||
},
|
||||
audiobookFromItems(items) {
|
||||
var { bookFiles, otherFiles, ignoredFiles } = this.filterAudiobookFiles(items)
|
||||
if (!bookFiles.length) {
|
||||
ignoredFiles = ignoredFiles.concat(otherFiles)
|
||||
otherFiles = []
|
||||
}
|
||||
return [
|
||||
{
|
||||
bookFiles,
|
||||
otherFiles,
|
||||
ignoredFiles
|
||||
}
|
||||
]
|
||||
},
|
||||
traverseForAudiobook(folder, depth = 1) {
|
||||
if (folder.items.some((f) => f.isDirectory)) {
|
||||
var audiobooks = []
|
||||
folder.items.forEach((file) => {
|
||||
if (file.isDirectory) {
|
||||
var audiobookResults = this.traverseForAudiobook(file, ++depth)
|
||||
audiobooks = audiobooks.concat(audiobookResults)
|
||||
}
|
||||
})
|
||||
return audiobooks
|
||||
} else {
|
||||
return this.audiobookFromItems(folder.items)
|
||||
}
|
||||
},
|
||||
fileTreeToAudiobooks(filetree) {
|
||||
// Has directores - Is Multi Book Drop
|
||||
if (filetree.some((f) => f.isDirectory)) {
|
||||
var ignoredFilesInRoot = filetree.filter((f) => !f.isDirectory)
|
||||
if (ignoredFilesInRoot.length) filetree = filetree.filter((f) => f.isDirectory)
|
||||
|
||||
var audiobookResults = this.traverseForAudiobook({ items: filetree })
|
||||
return {
|
||||
audiobooks: audiobookResults,
|
||||
ignoredFiles: ignoredFilesInRoot
|
||||
}
|
||||
} else {
|
||||
// Single Book drop
|
||||
return {
|
||||
audiobooks: this.audiobookFromItems(filetree),
|
||||
ignoredFiles: []
|
||||
}
|
||||
}
|
||||
},
|
||||
getFilesDropped(dataTransferItems) {
|
||||
var treemap = {
|
||||
path: '/',
|
||||
items: []
|
||||
}
|
||||
function traverseFileTreePromise(item, currtreemap) {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file) => {
|
||||
file.filepath = currtreemap.path + file.name //save full path
|
||||
currtreemap.items.push(file)
|
||||
resolve(file)
|
||||
})
|
||||
} else if (item.isDirectory) {
|
||||
let dirReader = item.createReader()
|
||||
currtreemap.items.push({
|
||||
isDirectory: true,
|
||||
dirname: item.name,
|
||||
path: currtreemap.path + item.name + '/',
|
||||
items: []
|
||||
})
|
||||
var newtreemap = currtreemap.items[currtreemap.items.length - 1]
|
||||
dirReader.readEntries((entries) => {
|
||||
let entriesPromises = []
|
||||
for (let entr of entries) entriesPromises.push(traverseFileTreePromise(entr, newtreemap))
|
||||
resolve(Promise.all(entriesPromises))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entriesPromises = []
|
||||
for (let it of dataTransferItems) {
|
||||
var filetree = traverseFileTreePromise(it.webkitGetAsEntry(), treemap)
|
||||
entriesPromises.push(filetree)
|
||||
}
|
||||
Promise.all(entriesPromises).then(() => {
|
||||
resolve(treemap.items)
|
||||
})
|
||||
})
|
||||
},
|
||||
cleanBook(book, index) {
|
||||
var audiobook = {
|
||||
index,
|
||||
title: '',
|
||||
author: '',
|
||||
series: '',
|
||||
...book
|
||||
}
|
||||
var firstBookFile = book.bookFiles[0]
|
||||
if (!firstBookFile.filepath) return audiobook // No path
|
||||
|
||||
var firstBookPath = Path.dirname(firstBookFile.filepath)
|
||||
|
||||
var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.')
|
||||
if (dirs.length) {
|
||||
audiobook.title = dirs.pop()
|
||||
if (dirs.length > 1) {
|
||||
audiobook.series = dirs.pop()
|
||||
}
|
||||
if (dirs.length) {
|
||||
audiobook.author = dirs.pop()
|
||||
}
|
||||
}
|
||||
return audiobook
|
||||
},
|
||||
async getBooksFromDataTransferItems(items) {
|
||||
var files = await this.getFilesDropped(items)
|
||||
if (!files || !files.length) return { error: 'No files found ' }
|
||||
var audiobooksData = this.fileTreeToAudiobooks(files)
|
||||
if (!audiobooksData.audiobooks.length && !audiobooksData.ignoredFiles.length) {
|
||||
return { error: 'Invalid file drop' }
|
||||
}
|
||||
var ignoredFiles = audiobooksData.ignoredFiles
|
||||
var index = 1
|
||||
var books = audiobooksData.audiobooks.filter((ab) => {
|
||||
if (!ab.bookFiles.length) {
|
||||
if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles)
|
||||
if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles)
|
||||
}
|
||||
return ab.bookFiles.length
|
||||
}).map(ab => this.cleanBook(ab, index++))
|
||||
return {
|
||||
books,
|
||||
invalidBooks,
|
||||
ignoredFiles
|
||||
}
|
||||
},
|
||||
getBooksFromFileList(filelist) {
|
||||
var ignoredFiles = []
|
||||
var otherFiles = []
|
||||
|
||||
var bookMap = {}
|
||||
|
||||
filelist.forEach((file) => {
|
||||
var filetype = this.checkFileType(file.name)
|
||||
if (!filetype) ignoredFiles.push(file)
|
||||
else {
|
||||
file.filetype = filetype
|
||||
if (file.webkitRelativePath) file.filepath = file.webkitRelativePath
|
||||
|
||||
if (filetype === 'audio' || filetype === 'ebook') {
|
||||
var dir = file.filepath ? Path.dirname(file.filepath) : ''
|
||||
if (!bookMap[dir]) {
|
||||
bookMap[dir] = {
|
||||
path: dir,
|
||||
ignoredFiles: [],
|
||||
bookFiles: [],
|
||||
otherFiles: []
|
||||
}
|
||||
}
|
||||
bookMap[dir].bookFiles.push(file)
|
||||
} else {
|
||||
otherFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
otherFiles.forEach((file) => {
|
||||
var dir = Path.dirname(file.filepath)
|
||||
var findBook = Object.values(bookMap).find(b => dir.startsWith(b.path))
|
||||
if (findBook) {
|
||||
bookMap[dir].otherFiles.push(file)
|
||||
} else {
|
||||
ignoredFiles.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
var index = 1
|
||||
var books = Object.values(bookMap).map(ab => this.cleanBook(ab, index++))
|
||||
return {
|
||||
books,
|
||||
ignoredFiles: ignoredFiles
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
@ -1,161 +1,99 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<main class="md:container mx-auto md:h-full max-w-screen-lg p-0 md:p-6">
|
||||
<article class="max-h-full md:overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||
<div class="w-full max-w-6xl mx-auto">
|
||||
<!-- Library & folder picker -->
|
||||
<div class="flex my-6 -mx-2">
|
||||
<div class="w-1/3 px-2">
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" :disabled="processing" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || processing" label="Folder" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/3 px-2">
|
||||
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
|
||||
<widgets-alert v-if="error" type="error">
|
||||
<p class="text-lg">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Picker display -->
|
||||
<div v-if="!books.length && !ignoredFiles.length" class="w-full mx-auto border border-white border-opacity-20 px-12 pt-12 pb-4 my-12 relative" :class="isDragging ? 'bg-primary bg-opacity-40' : 'border-dashed'">
|
||||
<p class="text-2xl text-center">{{ isDragging ? 'Drop files' : "Drag n' drop files or folders" }}</p>
|
||||
<p class="text-center text-sm my-5">or</p>
|
||||
<div class="w-full max-w-xl mx-auto">
|
||||
<div class="flex">
|
||||
<ui-btn class="w-full mx-1" @click="openFilePicker">Choose files</ui-btn>
|
||||
<ui-btn class="w-full mx-1" @click="openFolderPicker">Choose a folder</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="pt-8 text-center">
|
||||
<p class="text-xs text-white text-opacity-50 font-mono"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
</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>
|
||||
<!-- Book list header -->
|
||||
<div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10">
|
||||
<p class="text-lg">{{ books.length }} book{{ books.length === 1 ? '' : 's' }}</p>
|
||||
<p v-if="ignoredFiles.length" class="text-lg"> | {{ ignoredFiles.length }} file{{ ignoredFiles.length === 1 ? '' : 's' }} ignored</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing" small @click="reset">Reset</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<widgets-alert v-if="!books.length && !uploadReady" type="error" class="my-4">
|
||||
<p class="text-lg">No books found</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="ignoredFiles.length" type="warning" class="my-4">
|
||||
<div class="w-full pr-12">
|
||||
<p class="text-base mb-1">Unsupported files are ignored. When choosing or dropping a folder, other files that are not in a book folder are ignored.</p>
|
||||
<tables-uploaded-files-table :files="ignoredFiles" title="Ignored Files" class="text-white" />
|
||||
<p class="text-xs text-white text-opacity-50 font-mono pt-1"><strong>Supported File Types: </strong>{{ inputAccept.join(', ') }}</p>
|
||||
</div>
|
||||
</widgets-alert>
|
||||
|
||||
<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>
|
||||
<!-- Book Upload cards -->
|
||||
<template v-for="(book, index) in books">
|
||||
<cards-book-upload-card :ref="`bookCard-${book.index}`" :key="index" :book="book" :processing="processing" @remove="removeBook(book)" />
|
||||
</template>
|
||||
|
||||
<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 }}</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>
|
||||
<!-- Upload/Reset btns -->
|
||||
<div v-show="books.length" class="flex justify-end pb-8 pt-4">
|
||||
<ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">Upload</ui-btn>
|
||||
<ui-btn v-else @click="reset">Reset</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<input ref="fileFolderInput" id="hidden-input" type="file" webkitdirectory multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||
|
||||
export default {
|
||||
mixins: [uploadHelpers],
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus', '.mp4', '.aac'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus, .mp4, .aac',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
validImageFiles: [],
|
||||
invalidFiles: [],
|
||||
isDragging: false,
|
||||
error: '',
|
||||
books: [],
|
||||
ignoredFiles: [],
|
||||
selectedLibraryId: null,
|
||||
selectedFolderId: null
|
||||
selectedFolderId: null,
|
||||
processing: false,
|
||||
uploadFinished: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
inputAccept() {
|
||||
var extensions = []
|
||||
Object.values(this.$constants.SupportedFileTypes).forEach((types) => {
|
||||
extensions = extensions.concat(types.map((t) => `.${t}`))
|
||||
})
|
||||
return extensions
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
hasValidAudioFiles() {
|
||||
return this.validAudioFiles.length
|
||||
},
|
||||
directory() {
|
||||
if (!this.author || !this.title) return ''
|
||||
if (this.series) {
|
||||
return Path.join(this.author, this.series, this.title)
|
||||
} else {
|
||||
return Path.join(this.author, this.title)
|
||||
}
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
},
|
||||
@ -182,6 +120,9 @@ export default {
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadReady() {
|
||||
return !this.books.length && !this.ignoredFiles.length && !this.uploadFinished
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -200,114 +141,171 @@ export default {
|
||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.title = ''
|
||||
this.author = ''
|
||||
this.series = ''
|
||||
this.cancel()
|
||||
},
|
||||
cancel() {
|
||||
this.validAudioFiles = []
|
||||
this.validImageFiles = []
|
||||
this.invalidFiles = []
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
removeBook(book) {
|
||||
this.books = this.books.filter((b) => b.index !== book.index)
|
||||
if (!this.books.length) {
|
||||
this.reset()
|
||||
}
|
||||
this.showUploader = true
|
||||
},
|
||||
reset() {
|
||||
this.error = ''
|
||||
this.books = []
|
||||
this.ignoredFiles = []
|
||||
this.uploadFinished = false
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.value = ''
|
||||
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''
|
||||
},
|
||||
openFilePicker() {
|
||||
if (this.$refs.fileInput) this.$refs.fileInput.click()
|
||||
},
|
||||
openFolderPicker() {
|
||||
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.click()
|
||||
},
|
||||
isDraggingFile(e) {
|
||||
// Checks dragging file or folder and not an element on the page
|
||||
var dt = e.dataTransfer || {}
|
||||
return dt.types && dt.types.indexOf('Files') >= 0
|
||||
},
|
||||
dragenter(e) {
|
||||
e.preventDefault()
|
||||
if (this.uploadReady && this.isDraggingFile(e) && !this.isDragging) {
|
||||
this.isDragging = true
|
||||
}
|
||||
},
|
||||
dragleave(e) {
|
||||
e.preventDefault()
|
||||
if (!e.fromElement && this.isDragging) {
|
||||
this.isDragging = false
|
||||
}
|
||||
},
|
||||
dragover(e) {
|
||||
// This is required to catch the drop event
|
||||
e.preventDefault()
|
||||
},
|
||||
async drop(e) {
|
||||
e.preventDefault()
|
||||
this.isDragging = false
|
||||
var items = e.dataTransfer.items || []
|
||||
var bookResults = await this.uploadHelpers.getBooksFromDrop(items)
|
||||
this.setResults(bookResults)
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
this.filesChanged(_files)
|
||||
var bookResults = this.uploadHelpers.getBooksFromPicker(_files)
|
||||
this.setResults(bookResults)
|
||||
}
|
||||
},
|
||||
drop(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
const files = [...evt.dataTransfer.files]
|
||||
this.filesChanged(files)
|
||||
setResults(bookResults) {
|
||||
if (bookResults.error) {
|
||||
this.error = bookResults.error
|
||||
this.books = []
|
||||
this.ignoredFiles = []
|
||||
} else {
|
||||
this.error = ''
|
||||
this.books = bookResults.books
|
||||
this.ignoredFiles = bookResults.ignoredFiles
|
||||
}
|
||||
console.log('Upload results', bookResults)
|
||||
},
|
||||
dragover(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragleave(evt) {
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragenter(evt) {
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
preventDefaults(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
},
|
||||
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)
|
||||
}
|
||||
updateBookCardStatus(index, status) {
|
||||
var ref = this.$refs[`bookCard-${index}`]
|
||||
if (ref && ref.length) ref = ref[0]
|
||||
if (!ref) {
|
||||
console.error('Book card ref not found', index, this.$refs)
|
||||
} else {
|
||||
ref.setUploadStatus(status)
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
if (!this.selectedLibraryId || !this.selectedFolderId) {
|
||||
this.$toast.error('Must select a library and folder')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
uploadBook(book) {
|
||||
var form = new FormData()
|
||||
form.set('title', this.title)
|
||||
form.set('author', this.author)
|
||||
form.set('series', this.series)
|
||||
form.set('title', book.title)
|
||||
form.set('author', book.author)
|
||||
form.set('series', book.series)
|
||||
form.set('library', this.selectedLibraryId)
|
||||
form.set('folder', this.selectedFolderId)
|
||||
|
||||
var index = 0
|
||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||
files.forEach((file) => {
|
||||
book.files.forEach((file) => {
|
||||
form.set(`${index++}`, file)
|
||||
})
|
||||
|
||||
this.$axios
|
||||
return this.$axios
|
||||
.$post('/upload', form)
|
||||
.then((data) => {
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
this.processing = false
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||
this.$toast.error(errorMessage)
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
},
|
||||
validateBooks() {
|
||||
var bookData = []
|
||||
for (var book of this.books) {
|
||||
var bookref = this.$refs[`bookCard-${book.index}`]
|
||||
if (bookref && bookref.length) bookref = bookref[0]
|
||||
|
||||
if (!bookref) {
|
||||
console.error('Invalid book index no ref', book.index, this.$refs.bookCard)
|
||||
return false
|
||||
} else {
|
||||
var data = bookref.getData()
|
||||
if (!data) {
|
||||
return false
|
||||
}
|
||||
bookData.push(data)
|
||||
}
|
||||
}
|
||||
return bookData
|
||||
},
|
||||
async submit() {
|
||||
if (!this.selectedFolderId || !this.selectedLibraryId) {
|
||||
this.$toast.error('Must select library and folder')
|
||||
document.getElementById('page-wrapper').scroll({ top: 0, left: 0, behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
var books = this.validateBooks()
|
||||
if (!books) {
|
||||
this.$toast.error('Some invalid books')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var booksUploaded = 0
|
||||
var booksFailed = 0
|
||||
for (let i = 0; i < books.length; i++) {
|
||||
var book = books[i]
|
||||
this.updateBookCardStatus(book.index, 'uploading')
|
||||
var result = await this.uploadBook(book)
|
||||
if (result) booksUploaded++
|
||||
else booksFailed++
|
||||
this.updateBookCardStatus(book.index, result ? 'success' : 'failed')
|
||||
}
|
||||
if (booksUploaded) {
|
||||
this.$toast.success(`Successfully uploaded ${booksUploaded} book${booksUploaded > 1 ? 's' : ''}`)
|
||||
}
|
||||
if (booksFailed) {
|
||||
this.$toast.success(`Failed to upload ${booksFailed} book${booksFailed > 1 ? 's' : ''}`)
|
||||
}
|
||||
this.processing = false
|
||||
this.uploadFinished = true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
this.setDefaultFolder()
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
window.addEventListener('dragover', this.dragover)
|
||||
window.addEventListener('drop', this.drop)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('dragenter', this.dragenter)
|
||||
window.removeEventListener('dragleave', this.dragleave)
|
||||
window.removeEventListener('dragover', this.dragover)
|
||||
window.removeEventListener('drop', this.drop)
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,3 +1,12 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
opf: ['opf']
|
||||
}
|
||||
|
||||
const DownloadStatus = {
|
||||
PENDING: 0,
|
||||
READY: 1,
|
||||
@ -21,6 +30,7 @@ const BookshelfView = {
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
SupportedFileTypes,
|
||||
DownloadStatus,
|
||||
CoverDestination,
|
||||
BookCoverAspectRatio,
|
||||
|
@ -408,25 +408,27 @@ class Server {
|
||||
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
return res.status(500).error(`Library not found with id ${libraryId}`)
|
||||
return res.status(500).send(`Library not found with id ${libraryId}`)
|
||||
}
|
||||
var folder = library.folders.find(fold => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(500).error(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
return res.status(500).send(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
}
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.status(500).error(`Invalid post data`)
|
||||
if (!files.length || !title) {
|
||||
return res.status(500).send(`Invalid post data`)
|
||||
}
|
||||
|
||||
// For setting permissions recursively
|
||||
var firstDirPath = Path.join(folder.fullPath, author)
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
if (series && author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||
} else {
|
||||
} else if (author) {
|
||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
|
Loading…
Reference in New Issue
Block a user