Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to fetch book data on upload #2333

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 70 additions & 19 deletions client/components/cards/ItemUploadCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,33 @@

<div class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<div v-if="!isPodcast" class="flex items-end">
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
<div
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
@click="fetchMetadata">
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
</div>
</ui-tooltip>
</div>
<div v-else class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <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" />
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
</div>
</div>
</div>
<div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <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" />
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
</div>
</div>
</div>
Expand All @@ -48,8 +57,8 @@
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</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="$strings.MessageUploading" />
<div v-if="isNonInteractable" 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="nonInteractionLabel" />
</div>
</div>
</template>
Expand All @@ -61,10 +70,11 @@ export default {
props: {
item: {
type: Object,
default: () => {}
default: () => { }
},
mediaType: String,
processing: Boolean
processing: Boolean,
provider: String
},
data() {
return {
Expand All @@ -76,7 +86,8 @@ export default {
error: '',
isUploading: false,
uploadFailed: false,
uploadSuccess: false
uploadSuccess: false,
isFetchingMetadata: false
}
},
computed: {
Expand All @@ -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
}
}
},
Expand All @@ -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 = ''
Expand All @@ -128,4 +179,4 @@ export default {
}
}
}
</script>
</script>
82 changes: 67 additions & 15 deletions client/pages/upload/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
</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>
Expand Down Expand Up @@ -61,9 +75,7 @@
</widgets-alert>

<!-- Item Upload cards -->
<template v-for="item in items">
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template>
<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">
Expand Down Expand Up @@ -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()
}
}
},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`]
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -359,4 +411,4 @@ export default {
window.removeEventListener('drop', this.drop)
}
}
</script>
</script>
1 change: 1 addition & 0 deletions client/plugins/init.client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
Expand Down Expand Up @@ -266,6 +271,7 @@
"LabelExample": "Example",
"LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -738,4 +745,4 @@
"ToastSocketFailedToConnect": "Socket failed to connect",
"ToastUserDeleteFailed": "Failed to delete user",
"ToastUserDeleteSuccess": "User deleted"
}
}
Loading