<template> <div id="page-wrapper" class="page p-0 sm:p-6 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> <div class="w-full max-w-6xl mx-auto"> <!-- Library & folder picker --> <div class="flex my-6 -mx-2"> <div class="w-1/5 px-2"> <ui-dropdown v-model="selectedLibraryId" :items="libraryItems" :label="$strings.LabelLibrary" :disabled="!!items.length" @input="libraryChanged" /> </div> <div class="w-3/5 px-2"> <ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId || !!items.length" :label="$strings.LabelFolder" /> </div> <div class="w-1/5 px-2"> <ui-text-input-with-label :value="selectedLibraryMediaType" readonly :label="$strings.LabelMediaType" /> </div> </div> <div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6"> <label class="flex cursor-pointer pt-4"> <ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" /> <span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span> </label> <ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4"> <span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span> </ui-tooltip> <div class="flex-grow ml-4"> <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" /> </div> </div> <widgets-alert v-if="error" type="error"> <p class="text-lg">{{ error }}</p> </widgets-alert> <!-- Picker display --> <div v-if="!items.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 ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}</p> <p class="text-center text-sm my-5">{{ $strings.MessageOr }}</p> <div class="w-full max-w-xl mx-auto"> <div class="flex"> <ui-btn class="w-full mx-1" @click="openFilePicker">{{ $strings.ButtonChooseFiles }}</ui-btn> <ui-btn class="w-full mx-1" @click="openFolderPicker">{{ $strings.ButtonChooseAFolder }}</ui-btn> </div> </div> <div class="pt-8 text-center"> <p class="text-xs text-white text-opacity-50 font-mono mb-4"> <strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }} </p> <p class="text-sm text-white text-opacity-70"> {{ $strings.NoteUploaderFoldersWithMediaFiles }} <span v-if="selectedLibraryMediaType === 'book'">{{ $strings.NoteUploaderOnlyAudioFiles }}</span> </p> </div> </div> <!-- Item list header --> <div v-else class="w-full flex items-center pb-4 border-b border-white border-opacity-10"> <p class="text-lg">{{ items.length }} item{{ items.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">{{ $strings.ButtonReset }}</ui-btn> </div> <!-- Alerts --> <widgets-alert v-if="!items.length && !uploadReady" type="error" class="my-4"> <p class="text-lg">{{ $strings.MessageNoItemsFound }}</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">{{ $strings.NoteUploaderUnsupportedFiles }}</p> <tables-uploaded-files-table :files="ignoredFiles" :title="$strings.HeaderIgnoredFiles" class="text-white" /> <p class="text-xs text-white text-opacity-50 font-mono pt-1"> <strong>{{ $strings.LabelSupportedFileTypes }}: </strong>{{ inputAccept.join(', ') }} </p> </div> </widgets-alert> <!-- Item Upload cards --> <cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" /> <!-- Upload/Reset btns --> <div v-show="items.length" class="flex justify-end pb-8 pt-4"> <ui-btn v-if="!uploadFinished" color="success" :loading="processing" @click="submit">{{ $strings.ButtonUpload }}</ui-btn> <ui-btn v-else @click="reset">{{ $strings.ButtonReset }}</ui-btn> </div> </div> <input ref="fileInput" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" /> <input ref="fileFolderInput" 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 { isDragging: false, error: '', items: [], ignoredFiles: [], selectedLibraryId: null, selectedFolderId: null, processing: false, uploadFinished: false, fetchMetadata: { enabled: false, provider: null } } }, watch: { selectedLibrary(newVal) { if (newVal && !this.selectedFolderId) { this.setDefaultFolder() this.setMetadataProvider() } } }, computed: { inputAccept() { var extensions = [] Object.values(this.$constants.SupportedFileTypes).forEach((types) => { extensions = extensions.concat(types.map((t) => `.${t}`)) }) return extensions }, streamLibraryItem() { return this.$store.state.streamLibraryItem }, libraries() { return this.$store.state.libraries.libraries }, libraryItems() { return this.libraries.map((lib) => { return { value: lib.id, text: lib.name } }) }, selectedLibrary() { return this.libraries.find((lib) => lib.id === this.selectedLibraryId) }, selectedLibraryMediaType() { return this.selectedLibrary ? this.selectedLibrary.mediaType : null }, selectedLibraryIsPodcast() { return this.selectedLibraryMediaType === 'podcast' }, providers() { if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders return this.$store.state.scanners.providers }, canFetchMetadata() { return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled }, selectedFolder() { if (!this.selectedLibrary) return null return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId) }, folderItems() { if (!this.selectedLibrary) return [] return this.selectedLibrary.folders.map((fold) => { return { value: fold.id, text: fold.fullPath } }) }, uploadReady() { return !this.items.length && !this.ignoredFiles.length && !this.uploadFinished } }, methods: { libraryChanged() { if (!this.selectedLibrary && this.selectedFolderId) { this.selectedFolderId = null } else if (this.selectedFolderId) { if (!this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)) { this.selectedFolderId = null } } this.setDefaultFolder() this.setMetadataProvider() }, setDefaultFolder() { if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) { this.selectedFolderId = this.selectedLibrary.folders[0].id } }, setMetadataProvider() { this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId) }, removeItem(item) { this.items = this.items.filter((b) => b.index !== item.index) if (!this.items.length) { this.reset() } }, reset() { this.error = '' this.items = [] 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 itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType) this.onItemsSelected(itemResults) }, inputChanged(e) { if (!e.target || !e.target.files) return var _files = Array.from(e.target.files) if (_files && _files.length) { var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType) this.onItemsSelected(itemResults) } }, onItemsSelected(itemResults) { if (this.itemSelectionSuccessful(itemResults)) { // setTimeout ensures the new item ref is attached before this method is called setTimeout(this.attemptMetadataFetch, 0) } }, itemSelectionSuccessful(itemResults) { console.log('Upload results', itemResults) if (itemResults.error) { this.error = itemResults.error this.items = [] this.ignoredFiles = [] return false } this.error = '' this.items = itemResults.items this.ignoredFiles = itemResults.ignoredFiles return true }, attemptMetadataFetch() { if (!this.canFetchMetadata) { return false } this.items.forEach((item) => { let itemRef = this.$refs[`itemCard-${item.index}`] if (itemRef?.length) { itemRef[0].fetchMetadata(this.fetchMetadata.provider) } }) }, updateItemCardStatus(index, status) { var ref = this.$refs[`itemCard-${index}`] if (ref && ref.length) ref = ref[0] if (!ref) { console.error('Book card ref not found', index, this.$refs) } else { ref.setUploadStatus(status) } }, async uploadItem(item) { var form = new FormData() form.set('title', item.title) if (!this.selectedLibraryIsPodcast) { form.set('author', item.author || '') form.set('series', item.series || '') } form.set('library', this.selectedLibraryId) form.set('folder', this.selectedFolderId) var index = 0 item.files.forEach((file) => { form.set(`${index++}`, file) }) return this.$axios .$post('/api/upload', form) .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) return false }) }, validateItems() { var itemData = [] for (var item of this.items) { var itemref = this.$refs[`itemCard-${item.index}`] if (itemref && itemref.length) itemref = itemref[0] if (!itemref) { console.error('Invalid item index no ref', item.index, this.$refs.itemCard) return false } else { var data = itemref.getData() if (!data) { return false } itemData.push(data) } } return itemData }, 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 } const items = this.validateItems() if (!items) { this.$toast.error('Some invalid items') return } this.processing = true const itemsToUpload = [] // Check if path already exists before starting upload // uploading fails if path already exists for (const item of items) { const filepath = Path.join(this.selectedFolder.fullPath, item.directory) const exists = await this.$axios .$post(`/api/filesystem/pathexists`, { filepath }) .then((data) => { if (data.exists) { this.$toast.error(`Filepath "${filepath}" already exists on server`) } return data.exists }) .catch((error) => { console.error('Failed to check if filepath exists', error) return false }) if (!exists) { itemsToUpload.push(item) } } let itemsUploaded = 0 let itemsFailed = 0 for (const item of itemsToUpload) { this.updateItemCardStatus(item.index, 'uploading') const result = await this.uploadItem(item) if (result) itemsUploaded++ else itemsFailed++ this.updateItemCardStatus(item.index, result ? 'success' : 'failed') } if (itemsUploaded) { this.$toast.success(`Successfully uploaded ${itemsUploaded} item${itemsUploaded > 1 ? 's' : ''}`) } if (itemsFailed) { this.$toast.success(`Failed to upload ${itemsFailed} item${itemsFailed > 1 ? 's' : ''}`) } this.processing = false this.uploadFinished = true } }, mounted() { this.selectedLibraryId = this.$store.state.libraries.currentLibraryId this.setMetadataProvider() 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>