Skip to content

Commit fbc2c2b

Browse files
authored
Merge pull request #2333 from kieraneglin/ke/feature/upload-auto-fetch-data
Add ability to fetch book data on upload
2 parents f59516c + 57a5005 commit fbc2c2b

File tree

6 files changed

+168
-72
lines changed

6 files changed

+168
-72
lines changed

client/components/cards/ItemUploadCard.vue

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,33 @@
1515

1616
<div class="flex my-2 -mx-2">
1717
<div class="w-1/2 px-2">
18-
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
18+
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
1919
</div>
2020
<div class="w-1/2 px-2">
21-
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
21+
<div v-if="!isPodcast" class="flex items-end">
22+
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
23+
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
24+
<div
25+
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"
26+
@click="fetchMetadata">
27+
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
28+
</div>
29+
</ui-tooltip>
30+
</div>
2231
<div v-else class="w-full">
2332
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
24-
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
33+
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
2534
</div>
2635
</div>
2736
</div>
2837
<div v-if="!isPodcast" class="flex my-2 -mx-2">
2938
<div class="w-1/2 px-2">
30-
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
39+
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
3140
</div>
3241
<div class="w-1/2 px-2">
3342
<div class="w-full">
34-
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
35-
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
43+
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
44+
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
3645
</div>
3746
</div>
3847
</div>
@@ -48,8 +57,8 @@
4857
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
4958
</widgets-alert>
5059

51-
<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">
52-
<ui-loading-indicator :text="$strings.MessageUploading" />
60+
<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">
61+
<ui-loading-indicator :text="nonInteractionLabel" />
5362
</div>
5463
</div>
5564
</template>
@@ -61,10 +70,11 @@ export default {
6170
props: {
6271
item: {
6372
type: Object,
64-
default: () => {}
73+
default: () => { }
6574
},
6675
mediaType: String,
67-
processing: Boolean
76+
processing: Boolean,
77+
provider: String
6878
},
6979
data() {
7080
return {
@@ -76,7 +86,8 @@ export default {
7686
error: '',
7787
isUploading: false,
7888
uploadFailed: false,
79-
uploadSuccess: false
89+
uploadSuccess: false,
90+
isFetchingMetadata: false
8091
}
8192
},
8293
computed: {
@@ -87,12 +98,19 @@ export default {
8798
if (!this.itemData.title) return ''
8899
if (this.isPodcast) return this.itemData.title
89100
90-
if (this.itemData.series && this.itemData.author) {
91-
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
92-
} else if (this.itemData.author) {
93-
return Path.join(this.itemData.author, this.itemData.title)
94-
} else {
95-
return this.itemData.title
101+
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
102+
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
103+
104+
return Path.join(...cleanedOutputPathParts)
105+
},
106+
isNonInteractable() {
107+
return this.isUploading || this.isFetchingMetadata
108+
},
109+
nonInteractionLabel() {
110+
if (this.isUploading) {
111+
return this.$strings.MessageUploading
112+
} else if (this.isFetchingMetadata) {
113+
return this.$strings.LabelFetchingMetadata
96114
}
97115
}
98116
},
@@ -105,9 +123,42 @@ export default {
105123
titleUpdated() {
106124
this.error = ''
107125
},
126+
async fetchMetadata() {
127+
if (!this.itemData.title.trim().length) {
128+
return
129+
}
130+
131+
this.isFetchingMetadata = true
132+
this.error = ''
133+
134+
try {
135+
const searchQueryString = new URLSearchParams({
136+
title: this.itemData.title,
137+
author: this.itemData.author,
138+
provider: this.provider
139+
})
140+
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
141+
142+
if (bestCandidate) {
143+
this.itemData = {
144+
...this.itemData,
145+
title: bestCandidate.title,
146+
author: bestCandidate.author,
147+
series: (bestCandidate.series || [])[0]?.series
148+
}
149+
} else {
150+
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
151+
}
152+
} catch (e) {
153+
console.error('Failed', e)
154+
this.error = this.$strings.ErrorUploadFetchMetadataAPI
155+
} finally {
156+
this.isFetchingMetadata = false
157+
}
158+
},
108159
getData() {
109160
if (!this.itemData.title) {
110-
this.error = 'Must have a title'
161+
this.error = this.$strings.ErrorUploadLacksTitle
111162
return null
112163
}
113164
this.error = ''
@@ -128,4 +179,4 @@ export default {
128179
}
129180
}
130181
}
131-
</script>
182+
</script>

client/pages/upload/index.vue

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@
1414
</div>
1515
</div>
1616

17+
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
18+
<label class="flex cursor-pointer pt-4">
19+
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
20+
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
21+
</label>
22+
<ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4">
23+
<span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span>
24+
</ui-tooltip>
25+
26+
<div class="flex-grow ml-4">
27+
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
28+
</div>
29+
</div>
30+
1731
<widgets-alert v-if="error" type="error">
1832
<p class="text-lg">{{ error }}</p>
1933
</widgets-alert>
@@ -61,9 +75,7 @@
6175
</widgets-alert>
6276

6377
<!-- Item Upload cards -->
64-
<template v-for="item in items">
65-
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
66-
</template>
78+
<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)" />
6779

6880
<!-- Upload/Reset btns -->
6981
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
@@ -92,13 +104,18 @@ export default {
92104
selectedLibraryId: null,
93105
selectedFolderId: null,
94106
processing: false,
95-
uploadFinished: false
107+
uploadFinished: false,
108+
fetchMetadata: {
109+
enabled: false,
110+
provider: null
111+
}
96112
}
97113
},
98114
watch: {
99115
selectedLibrary(newVal) {
100116
if (newVal && !this.selectedFolderId) {
101117
this.setDefaultFolder()
118+
this.setMetadataProvider()
102119
}
103120
}
104121
},
@@ -133,6 +150,13 @@ export default {
133150
selectedLibraryIsPodcast() {
134151
return this.selectedLibraryMediaType === 'podcast'
135152
},
153+
providers() {
154+
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
155+
return this.$store.state.scanners.providers
156+
},
157+
canFetchMetadata() {
158+
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
159+
},
136160
selectedFolder() {
137161
if (!this.selectedLibrary) return null
138162
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
@@ -160,12 +184,16 @@ export default {
160184
}
161185
}
162186
this.setDefaultFolder()
187+
this.setMetadataProvider()
163188
},
164189
setDefaultFolder() {
165190
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
166191
this.selectedFolderId = this.selectedLibrary.folders[0].id
167192
}
168193
},
194+
setMetadataProvider() {
195+
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
196+
},
169197
removeItem(item) {
170198
this.items = this.items.filter((b) => b.index !== item.index)
171199
if (!this.items.length) {
@@ -213,27 +241,49 @@ export default {
213241
var items = e.dataTransfer.items || []
214242
215243
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
216-
this.setResults(itemResults)
244+
this.onItemsSelected(itemResults)
217245
},
218246
inputChanged(e) {
219247
if (!e.target || !e.target.files) return
220248
var _files = Array.from(e.target.files)
221249
if (_files && _files.length) {
222250
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
223-
this.setResults(itemResults)
251+
this.onItemsSelected(itemResults)
252+
}
253+
},
254+
onItemsSelected(itemResults) {
255+
if (this.itemSelectionSuccessful(itemResults)) {
256+
// setTimeout ensures the new item ref is attached before this method is called
257+
setTimeout(this.attemptMetadataFetch, 0)
224258
}
225259
},
226-
setResults(itemResults) {
260+
itemSelectionSuccessful(itemResults) {
261+
console.log('Upload results', itemResults)
262+
227263
if (itemResults.error) {
228264
this.error = itemResults.error
229265
this.items = []
230266
this.ignoredFiles = []
231-
} else {
232-
this.error = ''
233-
this.items = itemResults.items
234-
this.ignoredFiles = itemResults.ignoredFiles
267+
return false
235268
}
236-
console.log('Upload results', itemResults)
269+
270+
this.error = ''
271+
this.items = itemResults.items
272+
this.ignoredFiles = itemResults.ignoredFiles
273+
return true
274+
},
275+
attemptMetadataFetch() {
276+
if (!this.canFetchMetadata) {
277+
return false
278+
}
279+
280+
this.items.forEach((item) => {
281+
let itemRef = this.$refs[`itemCard-${item.index}`]
282+
283+
if (itemRef?.length) {
284+
itemRef[0].fetchMetadata(this.fetchMetadata.provider)
285+
}
286+
})
237287
},
238288
updateItemCardStatus(index, status) {
239289
var ref = this.$refs[`itemCard-${index}`]
@@ -248,8 +298,8 @@ export default {
248298
var form = new FormData()
249299
form.set('title', item.title)
250300
if (!this.selectedLibraryIsPodcast) {
251-
form.set('author', item.author)
252-
form.set('series', item.series)
301+
form.set('author', item.author || '')
302+
form.set('series', item.series || '')
253303
}
254304
form.set('library', this.selectedLibraryId)
255305
form.set('folder', this.selectedFolderId)
@@ -346,6 +396,8 @@ export default {
346396
},
347397
mounted() {
348398
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
399+
this.setMetadataProvider()
400+
349401
this.setDefaultFolder()
350402
window.addEventListener('dragenter', this.dragenter)
351403
window.addEventListener('dragleave', this.dragleave)
@@ -359,4 +411,4 @@ export default {
359411
window.removeEventListener('drop', this.drop)
360412
}
361413
}
362-
</script>
414+
</script>

client/plugins/init.client.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
7777
.replace(lineBreaks, replacement)
7878
.replace(windowsReservedRe, replacement)
7979
.replace(windowsTrailingRe, replacement)
80+
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
8081

8182
// Check if basename is too many bytes
8283
const ext = Path.extname(sanitized) // separate out file extension

client/strings/en-us.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@
8787
"ButtonUserEdit": "Edit user {0}",
8888
"ButtonViewAll": "View All",
8989
"ButtonYes": "Yes",
90+
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
91+
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
92+
"ErrorUploadLacksTitle": "Must have a title",
9093
"HeaderAccount": "Account",
9194
"HeaderAdvanced": "Advanced",
9295
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
@@ -196,6 +199,8 @@
196199
"LabelAuthorLastFirst": "Author (Last, First)",
197200
"LabelAuthors": "Authors",
198201
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
202+
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
203+
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
199204
"LabelAutoLaunch": "Auto Launch",
200205
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
201206
"LabelAutoRegister": "Auto Register",
@@ -266,6 +271,7 @@
266271
"LabelExample": "Example",
267272
"LabelExplicit": "Explicit",
268273
"LabelFeedURL": "Feed URL",
274+
"LabelFetchingMetadata": "Fetching Metadata",
269275
"LabelFile": "File",
270276
"LabelFileBirthtime": "File Birthtime",
271277
"LabelFileModified": "File Modified",
@@ -515,6 +521,7 @@
515521
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
516522
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
517523
"LabelUploaderDropFiles": "Drop files",
524+
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
518525
"LabelUseChapterTrack": "Use chapter track",
519526
"LabelUseFullTrack": "Use full track",
520527
"LabelUser": "User",
@@ -738,4 +745,4 @@
738745
"ToastSocketFailedToConnect": "Socket failed to connect",
739746
"ToastUserDeleteFailed": "Failed to delete user",
740747
"ToastUserDeleteSuccess": "User deleted"
741-
}
748+
}

0 commit comments

Comments
 (0)