diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index 21d97b2039..075c0fd4e2 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -15,24 +15,33 @@
- +
- +
+ + +
+ sync +
+
+

{{ $strings.LabelDirectory }} (auto)

- +
- +
-

{{ $strings.LabelDirectory }} (auto)

- + +
@@ -48,8 +57,8 @@

{{ $strings.MessageUploaderItemFailed }}

-
- +
+
@@ -61,10 +70,11 @@ export default { props: { item: { type: Object, - default: () => {} + default: () => { } }, mediaType: String, - processing: Boolean + processing: Boolean, + provider: String }, data() { return { @@ -76,7 +86,8 @@ export default { error: '', isUploading: false, uploadFailed: false, - uploadSuccess: false + uploadSuccess: false, + isFetchingMetadata: false } }, computed: { @@ -87,12 +98,19 @@ export default { if (!this.itemData.title) return '' if (this.isPodcast) return this.itemData.title - if (this.itemData.series && this.itemData.author) { - return Path.join(this.itemData.author, this.itemData.series, this.itemData.title) - } else if (this.itemData.author) { - return Path.join(this.itemData.author, this.itemData.title) - } else { - return this.itemData.title + const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title] + const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part)) + + return Path.join(...cleanedOutputPathParts) + }, + isNonInteractable() { + return this.isUploading || this.isFetchingMetadata + }, + nonInteractionLabel() { + if (this.isUploading) { + return this.$strings.MessageUploading + } else if (this.isFetchingMetadata) { + return this.$strings.LabelFetchingMetadata } } }, @@ -105,9 +123,42 @@ export default { titleUpdated() { this.error = '' }, + async fetchMetadata() { + if (!this.itemData.title.trim().length) { + return + } + + this.isFetchingMetadata = true + this.error = '' + + try { + const searchQueryString = new URLSearchParams({ + title: this.itemData.title, + author: this.itemData.author, + provider: this.provider + }) + const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`) + + if (bestCandidate) { + this.itemData = { + ...this.itemData, + title: bestCandidate.title, + author: bestCandidate.author, + series: (bestCandidate.series || [])[0]?.series + } + } else { + this.error = this.$strings.ErrorUploadFetchMetadataNoResults + } + } catch (e) { + console.error('Failed', e) + this.error = this.$strings.ErrorUploadFetchMetadataAPI + } finally { + this.isFetchingMetadata = false + } + }, getData() { if (!this.itemData.title) { - this.error = 'Must have a title' + this.error = this.$strings.ErrorUploadLacksTitle return null } this.error = '' @@ -128,4 +179,4 @@ export default { } } } - \ No newline at end of file + diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 09a9008bcd..547f5b05a1 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -14,6 +14,20 @@ +
+ + + info_outlined + + +
+ +
+
+

{{ error }}

@@ -61,9 +75,7 @@ - +
@@ -92,13 +104,18 @@ export default { selectedLibraryId: null, selectedFolderId: null, processing: false, - uploadFinished: false + uploadFinished: false, + fetchMetadata: { + enabled: false, + provider: null + } } }, watch: { selectedLibrary(newVal) { if (newVal && !this.selectedFolderId) { this.setDefaultFolder() + this.setMetadataProvider() } } }, @@ -133,6 +150,13 @@ export default { 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) @@ -160,12 +184,16 @@ export default { } } 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) { @@ -213,27 +241,49 @@ export default { var items = e.dataTransfer.items || [] var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType) - this.setResults(itemResults) + 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.setResults(itemResults) + 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) } }, - setResults(itemResults) { + itemSelectionSuccessful(itemResults) { + console.log('Upload results', itemResults) + if (itemResults.error) { this.error = itemResults.error this.items = [] this.ignoredFiles = [] - } else { - this.error = '' - this.items = itemResults.items - this.ignoredFiles = itemResults.ignoredFiles + return false } - console.log('Upload results', itemResults) + + 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}`] @@ -248,8 +298,8 @@ export default { var form = new FormData() form.set('title', item.title) if (!this.selectedLibraryIsPodcast) { - form.set('author', item.author) - form.set('series', item.series) + form.set('author', item.author || '') + form.set('series', item.series || '') } form.set('library', this.selectedLibraryId) form.set('folder', this.selectedFolderId) @@ -346,6 +396,8 @@ export default { }, mounted() { this.selectedLibraryId = this.$store.state.libraries.currentLibraryId + this.setMetadataProvider() + this.setDefaultFolder() window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragleave', this.dragleave) @@ -359,4 +411,4 @@ export default { window.removeEventListener('drop', this.drop) } } - \ No newline at end of file + diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js index 711c526ade..a16e6fa10f 100644 --- a/client/plugins/init.client.js +++ b/client/plugins/init.client.js @@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => { .replace(lineBreaks, replacement) .replace(windowsReservedRe, replacement) .replace(windowsTrailingRe, replacement) + .replace(/\s+/g, ' ') // Replace consecutive spaces with a single space // Check if basename is too many bytes const ext = Path.extname(sanitized) // separate out file extension diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8e1f6ce64d..857627e950 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -87,6 +87,9 @@ "ButtonUserEdit": "Edit user {0}", "ButtonViewAll": "View All", "ButtonYes": "Yes", + "ErrorUploadFetchMetadataAPI": "Error fetching metadata", + "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", + "ErrorUploadLacksTitle": "Must have a title", "HeaderAccount": "Account", "HeaderAdvanced": "Advanced", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", @@ -196,6 +199,8 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", + "LabelAutoFetchMetadata": "Auto Fetch Metadata", + "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", "LabelAutoLaunch": "Auto Launch", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path /login?autoLaunch=0)", "LabelAutoRegister": "Auto Register", @@ -266,6 +271,7 @@ "LabelExample": "Example", "LabelExplicit": "Explicit", "LabelFeedURL": "Feed URL", + "LabelFetchingMetadata": "Fetching Metadata", "LabelFile": "File", "LabelFileBirthtime": "File Birthtime", "LabelFileModified": "File Modified", @@ -515,6 +521,7 @@ "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDropFiles": "Drop files", + "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUseChapterTrack": "Use chapter track", "LabelUseFullTrack": "Use full track", "LabelUser": "User", @@ -738,4 +745,4 @@ "ToastSocketFailedToConnect": "Socket failed to connect", "ToastUserDeleteFailed": "Failed to delete user", "ToastUserDeleteSuccess": "User deleted" -} \ No newline at end of file +} diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 267db5c8e9..26a9d77bcd 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -8,6 +8,7 @@ const Database = require('../Database') const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') const { isObject, getTitleIgnorePrefix } = require('../utils/index') +const { sanitizeFilename } = require('../utils/fileUtils') const TaskManager = require('../managers/TaskManager') @@ -32,12 +33,9 @@ class MiscController { Logger.error('Invalid request, no files') return res.sendStatus(400) } + const files = Object.values(req.files) - const title = req.body.title - const author = req.body.author - const series = req.body.series - const libraryId = req.body.library - const folderId = req.body.folder + const { title, author, series, folder: folderId, library: libraryId } = req.body const library = await Database.libraryModel.getOldById(libraryId) if (!library) { @@ -52,43 +50,29 @@ class MiscController { return res.status(500).send(`Invalid post data`) } - // For setting permissions recursively - let outputDirectory = '' - let firstDirPath = '' - - if (library.isPodcast) { // Podcasts only in 1 folder - outputDirectory = Path.join(folder.fullPath, title) - firstDirPath = outputDirectory - } else { - firstDirPath = Path.join(folder.fullPath, author) - if (series && author) { - outputDirectory = Path.join(folder.fullPath, author, series, title) - } else if (author) { - outputDirectory = Path.join(folder.fullPath, author, title) - } else { - outputDirectory = Path.join(folder.fullPath, title) - } - } - - if (await fs.pathExists(outputDirectory)) { - Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`) - return res.status(500).send(`Directory "${outputDirectory}" already exists`) - } + // Podcasts should only be one folder deep + const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title] + // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`) + // before sanitizing all the directory parts to remove illegal chars and finally prepending + // the base folder path + const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part)) + const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts]) await fs.ensureDir(outputDirectory) Logger.info(`Uploading ${files.length} files to`, outputDirectory) - for (let i = 0; i < files.length; i++) { - var file = files[i] + for (const file of files) { + const path = Path.join(outputDirectory, sanitizeFilename(file.name)) - var path = Path.join(outputDirectory, file.name) - await file.mv(path).then(() => { - return true - }).catch((error) => { - Logger.error('Failed to move file', path, error) - return false - }) + await file.mv(path) + .then(() => { + return true + }) + .catch((error) => { + Logger.error('Failed to move file', path, error) + return false + }) } res.sendStatus(200) @@ -691,4 +675,4 @@ class MiscController { }) } } -module.exports = new MiscController() \ No newline at end of file +module.exports = new MiscController() diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 26578f5739..ebad97dbc9 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { .replace(lineBreaks, replacement) .replace(windowsReservedRe, replacement) .replace(windowsTrailingRe, replacement) + .replace(/\s+/g, ' ') // Replace consecutive spaces with a single space // Check if basename is too many bytes const ext = Path.extname(sanitized) // separate out file extension