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 @@
-
+
-
+
{{ $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