mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	New model update details, author and series inputs with create new, compare & copy utils
This commit is contained in:
		
							parent
							
								
									f2be3bc95e
								
							
						
					
					
						commit
						5f4e5cd3d8
					
				| @ -87,7 +87,6 @@ export default { | |||||||
|       var categories = await this.$axios |       var categories = await this.$axios | ||||||
|         .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) |         .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) | ||||||
|         .then((data) => { |         .then((data) => { | ||||||
|           console.log('Personalized data', data) |  | ||||||
|           return data |           return data | ||||||
|         }) |         }) | ||||||
|         .catch((error) => { |         .catch((error) => { | ||||||
|  | |||||||
| @ -168,10 +168,17 @@ export default { | |||||||
|     }, |     }, | ||||||
|     sublistItems() { |     sublistItems() { | ||||||
|       return (this[this.sublist] || []).map((item) => { |       return (this[this.sublist] || []).map((item) => { | ||||||
|  |         if (typeof item === 'string') { | ||||||
|           return { |           return { | ||||||
|             text: item, |             text: item, | ||||||
|             value: this.$encode(item) |             value: this.$encode(item) | ||||||
|           } |           } | ||||||
|  |         } else { | ||||||
|  |           return { | ||||||
|  |             text: item.name, | ||||||
|  |             value: item.id | ||||||
|  |           } | ||||||
|  |         } | ||||||
|       }) |       }) | ||||||
|     }, |     }, | ||||||
|     filterData() { |     filterData() { | ||||||
|  | |||||||
| @ -86,6 +86,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     selectedText() { |     selectedText() { | ||||||
|       var _selected = this.selected |       var _selected = this.selected | ||||||
|  |       if (!_selected) return '' | ||||||
|       if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.') |       if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.') | ||||||
|       var _sel = this.items.find((i) => i.value === _selected) |       var _sel = this.items.find((i) => i.value === _selected) | ||||||
|       if (!_sel) return '' |       if (!_sel) return '' | ||||||
|  | |||||||
| @ -18,7 +18,7 @@ | |||||||
|       <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> |       <div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> |     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative"> | ||||||
|       <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> |       <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> | ||||||
|     </div> |     </div> | ||||||
|   </modals-modal> |   </modals-modal> | ||||||
| @ -30,7 +30,7 @@ export default { | |||||||
|     return { |     return { | ||||||
|       processing: false, |       processing: false, | ||||||
|       libraryItem: null, |       libraryItem: null, | ||||||
|       fetchOnShow: false, | 
 | ||||||
|       tabs: [ |       tabs: [ | ||||||
|         { |         { | ||||||
|           id: 'details', |           id: 'details', | ||||||
| @ -62,11 +62,6 @@ export default { | |||||||
|           title: 'Match', |           title: 'Match', | ||||||
|           component: 'modals-edit-tabs-match' |           component: 'modals-edit-tabs-match' | ||||||
|         } |         } | ||||||
|         // { |  | ||||||
|         //   id: 'authors', |  | ||||||
|         //   title: 'Authors', |  | ||||||
|         //   component: 'modals-edit-tabs-authors' |  | ||||||
|         // } |  | ||||||
|       ] |       ] | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| @ -84,11 +79,6 @@ export default { | |||||||
|             this.selectedTab = availableTabIds[0] |             this.selectedTab = availableTabIds[0] | ||||||
|           } |           } | ||||||
| 
 | 
 | ||||||
|           if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) { |  | ||||||
|             if (this.fetchOnShow) this.fetchFull() |  | ||||||
|             return |  | ||||||
|           } |  | ||||||
|           this.fetchOnShow = false |  | ||||||
|           this.libraryItem = null |           this.libraryItem = null | ||||||
|           this.init() |           this.init() | ||||||
|           this.registerListeners() |           this.registerListeners() | ||||||
| @ -214,14 +204,10 @@ export default { | |||||||
|         this.selectedTab = tab |         this.selectedTab = tab | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     audiobookUpdated() { |     libraryItemUpdated(expandedLibraryItem) { | ||||||
|       if (!this.show) this.fetchOnShow = true |       this.libraryItem = expandedLibraryItem | ||||||
|       else { |  | ||||||
|         this.fetchFull() |  | ||||||
|       } |  | ||||||
|     }, |     }, | ||||||
|     init() { |     init() { | ||||||
|       this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId }) |  | ||||||
|       this.fetchFull() |       this.fetchFull() | ||||||
|     }, |     }, | ||||||
|     async fetchFull() { |     async fetchFull() { | ||||||
| @ -244,9 +230,11 @@ export default { | |||||||
|     }, |     }, | ||||||
|     registerListeners() { |     registerListeners() { | ||||||
|       this.$eventBus.$on('modal-hotkey', this.hotkey) |       this.$eventBus.$on('modal-hotkey', this.hotkey) | ||||||
|  |       this.$eventBus.$on(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) | ||||||
|     }, |     }, | ||||||
|     unregisterListeners() { |     unregisterListeners() { | ||||||
|       this.$eventBus.$off('modal-hotkey', this.hotkey) |       this.$eventBus.$off('modal-hotkey', this.hotkey) | ||||||
|  |       this.$eventBus.$off(`${this.selectedLibraryItemId}_updated`, this.libraryItemUpdated) | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() {}, |   mounted() {}, | ||||||
|  | |||||||
| @ -13,9 +13,7 @@ | |||||||
| 
 | 
 | ||||||
|         <div class="flex mt-2 -mx-1"> |         <div class="flex mt-2 -mx-1"> | ||||||
|           <div class="w-3/4 px-1"> |           <div class="w-3/4 px-1"> | ||||||
|             <!-- <ui-text-input-with-label v-model="details.authors" label="Author" /> --> |             <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" /> | ||||||
|             <!-- <p>Authors placeholder</p> --> |  | ||||||
|             <ui-multi-select-query-input ref="authorsSelect" v-model="authorNames" label="Authors" endpoint="authors/search" /> |  | ||||||
|           </div> |           </div> | ||||||
|           <div class="flex-grow px-1"> |           <div class="flex-grow px-1"> | ||||||
|             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> |             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> | ||||||
| @ -23,12 +21,8 @@ | |||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="flex mt-2 -mx-1"> |         <div class="flex mt-2 -mx-1"> | ||||||
|           <div class="w-3/4 px-1"> |  | ||||||
|             <p>Series placeholder</p> |  | ||||||
|             <!-- <ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" /> --> |  | ||||||
|           </div> |  | ||||||
|           <div class="flex-grow px-1"> |           <div class="flex-grow px-1"> | ||||||
|             <!-- <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> --> |             <ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" /> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
| @ -87,6 +81,27 @@ | |||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     </form> |     </form> | ||||||
|  | 
 | ||||||
|  |     <div v-if="showSeriesForm" class="absolute top-0 left-0 z-20 w-full h-full bg-black bg-opacity-50 rounded-lg flex items-center justify-center" @click="cancelSeriesForm"> | ||||||
|  |       <div class="absolute top-0 right-0 p-4"> | ||||||
|  |         <span class="material-icons text-gray-200 hover:text-white text-4xl cursor-pointer">close</span> | ||||||
|  |       </div> | ||||||
|  |       <form @submit.prevent="submitSeriesForm"> | ||||||
|  |         <div class="bg-bg rounded-lg p-8" @click.stop> | ||||||
|  |           <div class="flex"> | ||||||
|  |             <div class="flex-grow p-1 min-w-80"> | ||||||
|  |               <ui-input-dropdown ref="newSeriesSelect" v-model="selectedSeries.name" :items="existingSeriesNames" :disabled="!selectedSeries.id.startsWith('new')" label="Series Name" /> | ||||||
|  |             </div> | ||||||
|  |             <div class="w-40 p-1"> | ||||||
|  |               <ui-text-input-with-label v-model="selectedSeries.sequence" label="Sequence" /> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex justify-end mt-2 p-1"> | ||||||
|  |             <ui-btn type="submit">Save</ui-btn> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </form> | ||||||
|  |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -101,6 +116,8 @@ export default { | |||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|  |       selectedSeries: {}, | ||||||
|  |       showSeriesForm: false, | ||||||
|       details: { |       details: { | ||||||
|         title: null, |         title: null, | ||||||
|         subtitle: null, |         subtitle: null, | ||||||
| @ -116,7 +133,6 @@ export default { | |||||||
|         genres: [] |         genres: [] | ||||||
|       }, |       }, | ||||||
|       newTags: [], |       newTags: [], | ||||||
|       authorNames: [], |  | ||||||
|       resettingProgress: false, |       resettingProgress: false, | ||||||
|       isScrollable: false, |       isScrollable: false, | ||||||
|       savingMetadata: false, |       savingMetadata: false, | ||||||
| @ -180,9 +196,71 @@ export default { | |||||||
|     libraryScan() { |     libraryScan() { | ||||||
|       if (!this.libraryId) return null |       if (!this.libraryId) return null | ||||||
|       return this.$store.getters['scanners/getLibraryScan'](this.libraryId) |       return this.$store.getters['scanners/getLibraryScan'](this.libraryId) | ||||||
|  |     }, | ||||||
|  |     existingSeriesNames() { | ||||||
|  |       // Only show series names not already selected | ||||||
|  |       var alreadySelectedSeriesIds = this.details.series.map((se) => se.id) | ||||||
|  |       return this.series.filter((se) => !alreadySelectedSeriesIds.includes(se.id)).map((se) => se.name) | ||||||
|  |     }, | ||||||
|  |     seriesItems: { | ||||||
|  |       get() { | ||||||
|  |         return this.details.series.map((se) => { | ||||||
|  |           return { | ||||||
|  |             displayName: se.sequence ? `${se.name} #${se.sequence}` : se.name, | ||||||
|  |             ...se | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |       }, | ||||||
|  |       set(val) { | ||||||
|  |         this.details.series = val | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     cancelSeriesForm() { | ||||||
|  |       this.showSeriesForm = false | ||||||
|  |     }, | ||||||
|  |     editSeriesItem(series) { | ||||||
|  |       var _series = this.details.series.find((se) => se.id === series.id) | ||||||
|  |       if (!_series) return | ||||||
|  |       this.selectedSeries = { | ||||||
|  |         ..._series | ||||||
|  |       } | ||||||
|  |       this.showSeriesForm = true | ||||||
|  |     }, | ||||||
|  |     submitSeriesForm() { | ||||||
|  |       if (!this.selectedSeries.name) { | ||||||
|  |         this.$toast.error('Must enter a series') | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       if (this.$refs.newSeriesSelect) { | ||||||
|  |         this.$refs.newSeriesSelect.blur() | ||||||
|  |       } | ||||||
|  |       var existingSeriesIndex = this.details.series.findIndex((se) => se.id === this.selectedSeries.id) | ||||||
|  | 
 | ||||||
|  |       var seriesSameName = this.series.find((se) => se.name.toLowerCase() === this.selectedSeries.name.toLowerCase()) | ||||||
|  |       if (existingSeriesIndex < 0 && seriesSameName) { | ||||||
|  |         this.selectedSeries.id = seriesSameName.id | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (existingSeriesIndex >= 0) { | ||||||
|  |         this.details.series.splice(existingSeriesIndex, 1, { ...this.selectedSeries }) | ||||||
|  |       } else { | ||||||
|  |         this.details.series.push({ | ||||||
|  |           ...this.selectedSeries | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       this.showSeriesForm = false | ||||||
|  |     }, | ||||||
|  |     addNewSeries() { | ||||||
|  |       this.selectedSeries = { | ||||||
|  |         id: `new-${Date.now()}`, | ||||||
|  |         name: '', | ||||||
|  |         sequence: '' | ||||||
|  |       } | ||||||
|  |       this.showSeriesForm = true | ||||||
|  |     }, | ||||||
|     quickMatch() { |     quickMatch() { | ||||||
|       this.quickMatching = true |       this.quickMatching = true | ||||||
|       var matchOptions = { |       var matchOptions = { | ||||||
| @ -249,8 +327,8 @@ export default { | |||||||
|         return |         return | ||||||
|       } |       } | ||||||
|       this.isProcessing = true |       this.isProcessing = true | ||||||
|       if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) { |       if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) { | ||||||
|         this.$refs.seriesDropdown.blur() |         this.$refs.authorsSelect.forceBlur() | ||||||
|       } |       } | ||||||
|       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { |       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { | ||||||
|         this.$refs.genresSelect.forceBlur() |         this.$refs.genresSelect.forceBlur() | ||||||
| @ -262,11 +340,11 @@ export default { | |||||||
|     }, |     }, | ||||||
|     async handleForm() { |     async handleForm() { | ||||||
|       const updatePayload = { |       const updatePayload = { | ||||||
|         book: this.details, |         metadata: this.details, | ||||||
|         tags: this.newTags |         tags: this.newTags | ||||||
|       } |       } | ||||||
| 
 |       console.log('Sending update', updatePayload) | ||||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => { |       var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { | ||||||
|         console.error('Failed to update', error) |         console.error('Failed to update', error) | ||||||
|         return false |         return false | ||||||
|       }) |       }) | ||||||
| @ -280,18 +358,24 @@ export default { | |||||||
|       this.details.title = this.mediaMetadata.title |       this.details.title = this.mediaMetadata.title | ||||||
|       this.details.subtitle = this.mediaMetadata.subtitle |       this.details.subtitle = this.mediaMetadata.subtitle | ||||||
|       this.details.description = this.mediaMetadata.description |       this.details.description = this.mediaMetadata.description | ||||||
|       this.details.authors = this.mediaMetadata.authors || [] |       this.$set( | ||||||
|  |         this.details, | ||||||
|  |         'authors', | ||||||
|  |         (this.mediaMetadata.authors || []).map((se) => ({ ...se })) | ||||||
|  |       ) | ||||||
|       this.details.narrator = this.mediaMetadata.narrator |       this.details.narrator = this.mediaMetadata.narrator | ||||||
|       this.details.genres = this.mediaMetadata.genres || [] |       this.details.genres = [...(this.mediaMetadata.genres || [])] | ||||||
|       this.details.series = this.mediaMetadata.series |       this.$set( | ||||||
|  |         this.details, | ||||||
|  |         'series', | ||||||
|  |         (this.mediaMetadata.series || []).map((se) => ({ ...se })) | ||||||
|  |       ) | ||||||
|       this.details.publishYear = this.mediaMetadata.publishYear |       this.details.publishYear = this.mediaMetadata.publishYear | ||||||
|       this.details.publisher = this.mediaMetadata.publisher || null |       this.details.publisher = this.mediaMetadata.publisher || null | ||||||
|       this.details.language = this.mediaMetadata.language || null |       this.details.language = this.mediaMetadata.language || null | ||||||
|       this.details.isbn = this.mediaMetadata.isbn || null |       this.details.isbn = this.mediaMetadata.isbn || null | ||||||
|       this.details.asin = this.mediaMetadata.asin || null |       this.details.asin = this.mediaMetadata.asin || null | ||||||
| 
 |       this.newTags = [...(this.media.tags || [])] | ||||||
|       this.newTags = this.media.tags || [] |  | ||||||
|       this.authorNames = this.details.authors.map((au) => au.name) |  | ||||||
|     }, |     }, | ||||||
|     removeItem() { |     removeItem() { | ||||||
|       if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { |       if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { | ||||||
|  | |||||||
| @ -3,14 +3,15 @@ | |||||||
|     <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> |     <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> | ||||||
|     <div ref="wrapper" class="relative"> |     <div ref="wrapper" class="relative"> | ||||||
|       <form @submit.prevent="submitForm"> |       <form @submit.prevent="submitForm"> | ||||||
|         <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> |         <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|           <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> |           <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> | ||||||
|             <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> |             <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> | ||||||
|  |               <span v-if="showEdit" class="material-icons text-white hover:text-warning" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span> | ||||||
|               <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> |               <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> | ||||||
|             </div> |             </div> | ||||||
|             {{ item }} |             {{ item }} | ||||||
|           </div> |           </div> | ||||||
|           <input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> |           <input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> | ||||||
|         </div> |         </div> | ||||||
|       </form> |       </form> | ||||||
| 
 | 
 | ||||||
| @ -47,7 +48,9 @@ export default { | |||||||
|       default: () => [] |       default: () => [] | ||||||
|     }, |     }, | ||||||
|     label: String, |     label: String, | ||||||
|     disabled: Boolean |     disabled: Boolean, | ||||||
|  |     readonly: Boolean, | ||||||
|  |     showEdit: Boolean | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
| @ -76,6 +79,13 @@ export default { | |||||||
|     showMenu() { |     showMenu() { | ||||||
|       return this.isFocused |       return this.isFocused | ||||||
|     }, |     }, | ||||||
|  |     wrapperClass() { | ||||||
|  |       var classes = [] | ||||||
|  |       if (this.disabled) classes.push('bg-black-300') | ||||||
|  |       else classes.push('bg-primary') | ||||||
|  |       if (!this.readonly) classes.push('cursor-text') | ||||||
|  |       return classes.join(' ') | ||||||
|  |     }, | ||||||
|     itemsToShow() { |     itemsToShow() { | ||||||
|       if (!this.currentSearch || !this.textInput) { |       if (!this.currentSearch || !this.textInput) { | ||||||
|         return this.items |         return this.items | ||||||
| @ -88,6 +98,9 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     editItem(item) { | ||||||
|  |       this.$emit('edit', item) | ||||||
|  |     }, | ||||||
|     keydownInput() { |     keydownInput() { | ||||||
|       clearTimeout(this.typingTimeout) |       clearTimeout(this.typingTimeout) | ||||||
|       this.typingTimeout = setTimeout(() => { |       this.typingTimeout = setTimeout(() => { | ||||||
|  | |||||||
| @ -3,26 +3,30 @@ | |||||||
|     <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> |     <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> | ||||||
|     <div ref="wrapper" class="relative"> |     <div ref="wrapper" class="relative"> | ||||||
|       <form @submit.prevent="submitForm"> |       <form @submit.prevent="submitForm"> | ||||||
|         <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded-md px-2 py-1 cursor-text" :class="disabled ? 'bg-black-300' : 'bg-primary'" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> |         <div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-0.5" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|           <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> |           <div v-for="item in selected" :key="item.id" class="rounded-full px-2 py-0.5 m-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center justify-center relative min-w-12"> | ||||||
|             <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> |             <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> | ||||||
|               <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> |               <span v-if="showEdit" class="material-icons text-white hover:text-warning mr-1" style="font-size: 1rem" @click.stop="editItem(item)">edit</span> | ||||||
|  |               <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item.id)">close</span> | ||||||
|             </div> |             </div> | ||||||
|             {{ item }} |             {{ item[textKey] }} | ||||||
|           </div> |           </div> | ||||||
|           <input ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> |           <div v-if="showEdit" class="rounded-full cursor-pointer w-6 h-6 mx-0.5 bg-bg flex items-center justify-center"> | ||||||
|  |             <span class="material-icons text-white hover:text-success pt-px pr-px" style="font-size: 1.1rem" @click.stop="addItem">add</span> | ||||||
|  |           </div> | ||||||
|  |           <input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> | ||||||
|         </div> |         </div> | ||||||
|       </form> |       </form> | ||||||
| 
 | 
 | ||||||
|       <ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> |       <ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> | ||||||
|         <template v-for="item in itemsToShow"> |         <template v-for="item in itemsToShow"> | ||||||
|           <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item.name)" @mouseup.stop.prevent @mousedown.prevent> |           <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|             <div class="flex items-center"> |             <div class="flex items-center"> | ||||||
|               <span class="font-normal ml-3 block truncate">{{ item.name }}</span> |               <span class="font-normal ml-3 block truncate">{{ item.name }}</span> | ||||||
|             </div> |             </div> | ||||||
|             <!-- <span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> |             <span v-if="getIsSelected(item.id)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> | ||||||
|               <span class="material-icons text-xl">checkmark</span> |               <span class="material-icons text-xl">checkmark</span> | ||||||
|             </span> --> |             </span> | ||||||
|           </li> |           </li> | ||||||
|         </template> |         </template> | ||||||
|         <li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> |         <li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> | ||||||
| @ -44,7 +48,13 @@ export default { | |||||||
|     }, |     }, | ||||||
|     endpoint: String, |     endpoint: String, | ||||||
|     label: String, |     label: String, | ||||||
|     disabled: Boolean |     disabled: Boolean, | ||||||
|  |     readonly: Boolean, | ||||||
|  |     showEdit: Boolean, | ||||||
|  |     textKey: { | ||||||
|  |       type: String, | ||||||
|  |       default: 'name' | ||||||
|  |     } | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
| @ -66,20 +76,36 @@ export default { | |||||||
|   computed: { |   computed: { | ||||||
|     selected: { |     selected: { | ||||||
|       get() { |       get() { | ||||||
|         return this.value |         return this.value || [] | ||||||
|       }, |       }, | ||||||
|       set(val) { |       set(val) { | ||||||
|         this.$emit('input', val) |         this.$emit('input', val) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     wrapperClass() { | ||||||
|  |       var classes = [] | ||||||
|  |       if (this.disabled) classes.push('bg-black-300') | ||||||
|  |       else classes.push('bg-primary') | ||||||
|  |       if (!this.readonly) classes.push('cursor-text') | ||||||
|  |       return classes.join(' ') | ||||||
|  |     }, | ||||||
|     showMenu() { |     showMenu() { | ||||||
|       return this.isFocused |       return this.isFocused && this.currentSearch | ||||||
|     }, |     }, | ||||||
|     itemsToShow() { |     itemsToShow() { | ||||||
|       return this.items |       return this.items | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     addItem() { | ||||||
|  |       this.$emit('add') | ||||||
|  |     }, | ||||||
|  |     editItem(item) { | ||||||
|  |       this.$emit('edit', item) | ||||||
|  |     }, | ||||||
|  |     getIsSelected(itemValue) { | ||||||
|  |       return !!this.selected.find((i) => i.id === itemValue) | ||||||
|  |     }, | ||||||
|     async search() { |     async search() { | ||||||
|       if (this.searching) return |       if (this.searching) return | ||||||
|       this.currentSearch = this.textInput |       this.currentSearch = this.textInput | ||||||
| @ -96,7 +122,7 @@ export default { | |||||||
|       clearTimeout(this.typingTimeout) |       clearTimeout(this.typingTimeout) | ||||||
|       this.typingTimeout = setTimeout(() => { |       this.typingTimeout = setTimeout(() => { | ||||||
|         this.search() |         this.search() | ||||||
|       }, 500) |       }, 250) | ||||||
|       this.setInputWidth() |       this.setInputWidth() | ||||||
|     }, |     }, | ||||||
|     setInputWidth() { |     setInputWidth() { | ||||||
| @ -165,7 +191,7 @@ export default { | |||||||
|       if (this.textInput) this.submitForm() |       if (this.textInput) this.submitForm() | ||||||
|       if (this.$refs.input) this.$refs.input.blur() |       if (this.$refs.input) this.$refs.input.blur() | ||||||
|     }, |     }, | ||||||
|     clickedOption(e, itemValue) { |     clickedOption(e, item) { | ||||||
|       if (e) { |       if (e) { | ||||||
|         e.stopPropagation() |         e.stopPropagation() | ||||||
|         e.preventDefault() |         e.preventDefault() | ||||||
| @ -173,11 +199,11 @@ export default { | |||||||
|       if (this.$refs.input) this.$refs.input.focus() |       if (this.$refs.input) this.$refs.input.focus() | ||||||
| 
 | 
 | ||||||
|       var newSelected = null |       var newSelected = null | ||||||
|       if (this.selected.includes(itemValue)) { |       if (this.getIsSelected(item.id)) { | ||||||
|         newSelected = this.selected.filter((s) => s !== itemValue) |         newSelected = this.selected.filter((s) => s.id !== item.id) | ||||||
|         this.$emit('removedItem', itemValue) |         this.$emit('removedItem', item.id) | ||||||
|       } else { |       } else { | ||||||
|         newSelected = this.selected.concat([itemValue]) |         newSelected = this.selected.concat([item]) | ||||||
|       } |       } | ||||||
|       this.textInput = null |       this.textInput = null | ||||||
|       this.currentSearch = null |       this.currentSearch = null | ||||||
| @ -193,10 +219,10 @@ export default { | |||||||
|       } |       } | ||||||
|       this.focus() |       this.focus() | ||||||
|     }, |     }, | ||||||
|     removeItem(item) { |     removeItem(itemId) { | ||||||
|       var remaining = this.selected.filter((i) => i !== item) |       var remaining = this.selected.filter((i) => i.id !== itemId) | ||||||
|       this.$emit('input', remaining) |       this.$emit('input', remaining) | ||||||
|       this.$emit('removedItem', item) |       this.$emit('removedItem', itemId) | ||||||
|       this.$nextTick(() => { |       this.$nextTick(() => { | ||||||
|         this.recalcMenuPos() |         this.recalcMenuPos() | ||||||
|       }) |       }) | ||||||
| @ -221,7 +247,10 @@ export default { | |||||||
|       if (matchesItem) { |       if (matchesItem) { | ||||||
|         this.clickedOption(null, matchesItem) |         this.clickedOption(null, matchesItem) | ||||||
|       } else { |       } else { | ||||||
|         this.insertNewItem(this.textInput) |         this.insertNewItem({ | ||||||
|  |           id: `new-${Date.now()}`, | ||||||
|  |           name: this.textInput | ||||||
|  |         }) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     scroll() { |     scroll() { | ||||||
|  | |||||||
							
								
								
									
										157
									
								
								client/components/ui/QueryInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								client/components/ui/QueryInput.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,157 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="w-full"> | ||||||
|  |     <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p> | ||||||
|  |     <div ref="wrapper" class="relative"> | ||||||
|  |       <form @submit.prevent="submitForm"> | ||||||
|  |         <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'"> | ||||||
|  |           <input ref="input" v-model="textInput" :disabled="disabled" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> | ||||||
|  |         </div> | ||||||
|  |       </form> | ||||||
|  | 
 | ||||||
|  |       <ul ref="menu" v-show="isFocused && currentSearch" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> | ||||||
|  |         <template v-for="item in items"> | ||||||
|  |           <li :key="item.id" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|  |             <div class="flex items-center"> | ||||||
|  |               <span class="font-normal ml-3 block truncate">{{ item.name }}</span> | ||||||
|  |             </div> | ||||||
|  |             <span v-if="isItemSelected(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> | ||||||
|  |               <span class="material-icons text-xl">checkmark</span> | ||||||
|  |             </span> | ||||||
|  |           </li> | ||||||
|  |         </template> | ||||||
|  |         <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> | ||||||
|  |           <div class="flex items-center justify-center"> | ||||||
|  |             <span class="font-normal">No items</span> | ||||||
|  |           </div> | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   props: { | ||||||
|  |     value: String, | ||||||
|  |     disabled: Boolean, | ||||||
|  |     label: String, | ||||||
|  |     endpoint: String | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       isFocused: false, | ||||||
|  |       currentSearch: null, | ||||||
|  |       typingTimeout: null, | ||||||
|  |       textInput: null, | ||||||
|  |       searching: false, | ||||||
|  |       items: [], | ||||||
|  |       selectedItemObject: null | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     value: { | ||||||
|  |       immediate: true, | ||||||
|  |       handler(newVal) { | ||||||
|  |         this.textInput = newVal | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     input: { | ||||||
|  |       get() { | ||||||
|  |         return this.value || '' | ||||||
|  |       }, | ||||||
|  |       set(val) { | ||||||
|  |         this.$emit('input', val) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     isItemSelected(item) { | ||||||
|  |       return !!this.input.toLowerCase() === item.name | ||||||
|  |     }, | ||||||
|  |     async search() { | ||||||
|  |       if (this.searching) return | ||||||
|  |       this.currentSearch = this.textInput | ||||||
|  |       this.searching = true | ||||||
|  |       var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15`).catch((error) => { | ||||||
|  |         console.error('Failed to get search results', error) | ||||||
|  |         return [] | ||||||
|  |       }) | ||||||
|  |       console.log('Search results', results) | ||||||
|  |       this.items = results || [] | ||||||
|  |       this.searching = false | ||||||
|  |     }, | ||||||
|  |     keydownInput() { | ||||||
|  |       clearTimeout(this.typingTimeout) | ||||||
|  |       this.typingTimeout = setTimeout(() => { | ||||||
|  |         this.search() | ||||||
|  |       }, 250) | ||||||
|  |     }, | ||||||
|  |     inputFocus() { | ||||||
|  |       this.isFocused = true | ||||||
|  |     }, | ||||||
|  |     blur() { | ||||||
|  |       // Handle blur immediately | ||||||
|  |       this.isFocused = false | ||||||
|  |       if (this.inputName.toLowerCase() !== this.textInput.toLowerCase()) { | ||||||
|  |         var val = this.textInput ? this.textInput.trim() : null | ||||||
|  |         if (val) { | ||||||
|  |           this.submitForm() | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (this.$refs.input) { | ||||||
|  |         this.$refs.input.blur() | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     inputBlur() { | ||||||
|  |       if (!this.isFocused) return | ||||||
|  | 
 | ||||||
|  |       setTimeout(() => { | ||||||
|  |         if (document.activeElement === this.$refs.input) { | ||||||
|  |           return | ||||||
|  |         } | ||||||
|  |         this.isFocused = false | ||||||
|  |         if (this.input !== this.textInput) { | ||||||
|  |           var val = this.textInput ? this.textInput.trim() : null | ||||||
|  |           if (val) { | ||||||
|  |             this.setItem(val) | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }, 50) | ||||||
|  |     }, | ||||||
|  |     submitForm() { | ||||||
|  |       var val = this.textInput ? this.textInput.trim() : null | ||||||
|  |       if (val) { | ||||||
|  |         this.setItem(val) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     setItem(itemText) { | ||||||
|  |       if (!this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase())) { | ||||||
|  |         var newItem = { | ||||||
|  |           id: `new-${Date.now()}`, | ||||||
|  |           name: val | ||||||
|  |         } | ||||||
|  |         this.$emit('selected', newItem) | ||||||
|  |         this.input = val | ||||||
|  |       } else { | ||||||
|  |         var item = this.items.find((i) => i.name.toLowerCase() !== val.toLowerCase()) | ||||||
|  |         this.$emit('selected', item) | ||||||
|  |         this.input = item.name | ||||||
|  |       } | ||||||
|  |       this.currentSearch = null | ||||||
|  |     }, | ||||||
|  |     clickedOption(e, item) { | ||||||
|  |       this.textInput = item.name | ||||||
|  |       this.currentSearch = null | ||||||
|  |       this.input = item.name | ||||||
|  |       this.selectedItemObject = item | ||||||
|  |       this.$emit('selected', item) | ||||||
|  | 
 | ||||||
|  |       if (this.$refs.input) this.$refs.input.blur() | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
| @ -209,6 +209,12 @@ export default { | |||||||
|     libraryRemoved(library) { |     libraryRemoved(library) { | ||||||
|       this.$store.commit('libraries/remove', library) |       this.$store.commit('libraries/remove', library) | ||||||
|     }, |     }, | ||||||
|  |     libraryItemUpdated(libraryItem) { | ||||||
|  |       if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { | ||||||
|  |         this.$store.commit('setSelectedLibraryItem', libraryItem) | ||||||
|  |       } | ||||||
|  |       this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) | ||||||
|  |     }, | ||||||
|     scanComplete(data) { |     scanComplete(data) { | ||||||
|       console.log('Scan complete received', data) |       console.log('Scan complete received', data) | ||||||
| 
 | 
 | ||||||
| @ -395,6 +401,9 @@ export default { | |||||||
|       this.socket.on('library_added', this.libraryAdded) |       this.socket.on('library_added', this.libraryAdded) | ||||||
|       this.socket.on('library_removed', this.libraryRemoved) |       this.socket.on('library_removed', this.libraryRemoved) | ||||||
| 
 | 
 | ||||||
|  |       // Library Item Listeners | ||||||
|  |       this.socket.on('item_updated', this.libraryItemUpdated) | ||||||
|  | 
 | ||||||
|       // User Listeners |       // User Listeners | ||||||
|       this.socket.on('user_updated', this.userUpdated) |       this.socket.on('user_updated', this.userUpdated) | ||||||
|       this.socket.on('user_online', this.userOnline) |       this.socket.on('user_online', this.userOnline) | ||||||
|  | |||||||
| @ -15,6 +15,7 @@ const CollectionController = require('./controllers/CollectionController') | |||||||
| const MeController = require('./controllers/MeController') | const MeController = require('./controllers/MeController') | ||||||
| const BackupController = require('./controllers/BackupController') | const BackupController = require('./controllers/BackupController') | ||||||
| const LibraryItemController = require('./controllers/LibraryItemController') | const LibraryItemController = require('./controllers/LibraryItemController') | ||||||
|  | const SeriesController = require('./controllers/SeriesController') | ||||||
| 
 | 
 | ||||||
| const BookFinder = require('./finders/BookFinder') | const BookFinder = require('./finders/BookFinder') | ||||||
| const AuthorFinder = require('./finders/AuthorFinder') | const AuthorFinder = require('./finders/AuthorFinder') | ||||||
| @ -74,6 +75,8 @@ class ApiController { | |||||||
|     // Item Routes
 |     // Item Routes
 | ||||||
|     //
 |     //
 | ||||||
|     this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) |     this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) | ||||||
|  |     this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this)) | ||||||
|  |     this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) | ||||||
|     this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) |     this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) | ||||||
| 
 | 
 | ||||||
|     //
 |     //
 | ||||||
| @ -152,7 +155,7 @@ class ApiController { | |||||||
|     this.router.get('/filesystem', FileSystemController.getPaths.bind(this)) |     this.router.get('/filesystem', FileSystemController.getPaths.bind(this)) | ||||||
| 
 | 
 | ||||||
|     //
 |     //
 | ||||||
|     // Others
 |     // Author Routes
 | ||||||
|     //
 |     //
 | ||||||
|     this.router.get('/authors', this.getAuthors.bind(this)) |     this.router.get('/authors', this.getAuthors.bind(this)) | ||||||
|     this.router.get('/authors/search', this.searchAuthors.bind(this)) |     this.router.get('/authors/search', this.searchAuthors.bind(this)) | ||||||
| @ -161,6 +164,15 @@ class ApiController { | |||||||
|     this.router.patch('/authors/:id', this.updateAuthor.bind(this)) |     this.router.patch('/authors/:id', this.updateAuthor.bind(this)) | ||||||
|     this.router.delete('/authors/:id', this.deleteAuthor.bind(this)) |     this.router.delete('/authors/:id', this.deleteAuthor.bind(this)) | ||||||
| 
 | 
 | ||||||
|  |     //
 | ||||||
|  |     // Series Routes
 | ||||||
|  |     //
 | ||||||
|  |     this.router.get('/series/search', SeriesController.search.bind(this)) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     //
 | ||||||
|  |     // Misc Routes
 | ||||||
|  |     //
 | ||||||
|     this.router.patch('/serverSettings', this.updateServerSettings.bind(this)) |     this.router.patch('/serverSettings', this.updateServerSettings.bind(this)) | ||||||
| 
 | 
 | ||||||
|     this.router.post('/authorize', this.authorize.bind(this)) |     this.router.post('/authorize', this.authorize.bind(this)) | ||||||
|  | |||||||
							
								
								
									
										14
									
								
								server/Db.js
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								server/Db.js
									
									
									
									
									
								
							| @ -184,6 +184,20 @@ class Db { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async updateLibraryItem(libraryItem) { | ||||||
|  |     if (libraryItem && libraryItem.saveMetadata) { | ||||||
|  |       await libraryItem.saveMetadata() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return this.libraryItemsDb.update((record) => record.id === libraryItem.id, () => libraryItem).then((results) => { | ||||||
|  |       Logger.debug(`[DB] Library Item updated ${results.updated}`) | ||||||
|  |       return true | ||||||
|  |     }).catch((error) => { | ||||||
|  |       Logger.error(`[DB] Library Item update failed ${error}`) | ||||||
|  |       return false | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async updateAudiobook(audiobook) { |   async updateAudiobook(audiobook) { | ||||||
|     if (audiobook && audiobook.saveAbMetadata) { |     if (audiobook && audiobook.saveAbMetadata) { | ||||||
|       // TODO: Book may have updates where this save is not necessary
 |       // TODO: Book may have updates where this save is not necessary
 | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
|  | const Author = require('../objects/entities/Author') | ||||||
|  | const Series = require('../objects/entities/Series') | ||||||
| const { reqSupportsWebp } = require('../utils/index') | const { reqSupportsWebp } = require('../utils/index') | ||||||
| 
 | 
 | ||||||
| class LibraryItemController { | class LibraryItemController { | ||||||
| @ -9,6 +11,95 @@ class LibraryItemController { | |||||||
|     res.json(req.libraryItem) |     res.json(req.libraryItem) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async update(req, res) { | ||||||
|  |     if (!req.user.canUpdate) { | ||||||
|  |       Logger.warn('User attempted to update without permission', req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } | ||||||
|  |     var libraryItem = req.libraryItem | ||||||
|  |     // Item has cover and update is removing cover so purge it from cache
 | ||||||
|  |     if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { | ||||||
|  |       await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var hasUpdates = libraryItem.update(req.body) | ||||||
|  |     if (hasUpdates) { | ||||||
|  |       Logger.debug(`[LibraryItemController] Updated now saving`) | ||||||
|  |       await this.db.updateLibraryItem(libraryItem) | ||||||
|  |       this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|  |     } | ||||||
|  |     res.json(libraryItem.toJSON()) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   //
 | ||||||
|  |   // PATCH: will create new authors & series if in payload
 | ||||||
|  |   //
 | ||||||
|  |   async updateMedia(req, res) { | ||||||
|  |     if (!req.user.canUpdate) { | ||||||
|  |       Logger.warn('User attempted to update without permission', req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var libraryItem = req.libraryItem | ||||||
|  |     var mediaPayload = req.body | ||||||
|  |     // Item has cover and update is removing cover so purge it from cache
 | ||||||
|  |     if (libraryItem.media.coverPath && (mediaPayload.coverPath === '' || mediaPayload.coverPath === null)) { | ||||||
|  |       await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (mediaPayload.metadata) { | ||||||
|  |       var mediaMetadata = mediaPayload.metadata | ||||||
|  | 
 | ||||||
|  |       // Create new authors if in payload
 | ||||||
|  |       if (mediaMetadata.authors && mediaMetadata.authors.length) { | ||||||
|  |         // TODO: validate authors
 | ||||||
|  |         var newAuthors = [] | ||||||
|  |         for (let i = 0; i < mediaMetadata.authors.length; i++) { | ||||||
|  |           if (mediaMetadata.authors[i].id.startsWith('new')) { | ||||||
|  |             var newAuthor = new Author() | ||||||
|  |             newAuthor.setData(mediaMetadata.authors[i]) | ||||||
|  |             Logger.debug(`[LibraryItemController] Created new author "${newAuthor.name}"`) | ||||||
|  |             newAuthors.push(newAuthor) | ||||||
|  |             // Update ID in original payload
 | ||||||
|  |             mediaMetadata.authors[i].id = newAuthor.id | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         if (newAuthors.length) { | ||||||
|  |           await this.db.insertEntities('author', newAuthors) | ||||||
|  |           this.emitter('authors_added', newAuthors) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Create new series if in payload
 | ||||||
|  |       if (mediaMetadata.series && mediaMetadata.series.length) { | ||||||
|  |         // TODO: validate series
 | ||||||
|  |         var newSeries = [] | ||||||
|  |         for (let i = 0; i < mediaMetadata.series.length; i++) { | ||||||
|  |           if (mediaMetadata.series[i].id.startsWith('new')) { | ||||||
|  |             var newSeriesItem = new Series() | ||||||
|  |             newSeriesItem.setData(mediaMetadata.series[i]) | ||||||
|  |             Logger.debug(`[LibraryItemController] Created new series "${newSeriesItem.name}"`) | ||||||
|  |             newSeries.push(newSeriesItem) | ||||||
|  |             // Update ID in original payload
 | ||||||
|  |             mediaMetadata.series[i].id = newSeriesItem.id | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |         if (newSeries.length) { | ||||||
|  |           await this.db.insertEntities('series', newSeries) | ||||||
|  |           this.emitter('authors_added', newSeries) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var hasUpdates = libraryItem.media.update(mediaPayload) | ||||||
|  |     if (hasUpdates) { | ||||||
|  |       Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) | ||||||
|  |       await this.db.updateLibraryItem(libraryItem) | ||||||
|  |       this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|  |     } | ||||||
|  |     res.json(libraryItem) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // GET api/items/:id/cover
 |   // GET api/items/:id/cover
 | ||||||
|   async getCover(req, res) { |   async getCover(req, res) { | ||||||
|     let { query: { width, height, format }, libraryItem } = req |     let { query: { width, height, format }, libraryItem } = req | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								server/controllers/SeriesController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								server/controllers/SeriesController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | const Logger = require('../Logger') | ||||||
|  | 
 | ||||||
|  | class SeriesController { | ||||||
|  |   constructor() { } | ||||||
|  | 
 | ||||||
|  |   async search(req, res) { | ||||||
|  |     var q = (req.query.q || '').toLowerCase() | ||||||
|  |     if (!q) return res.json([]) | ||||||
|  |     var limit = (req.query.limit && !isNaN(req.query.limit)) ? Number(req.query.limit) : 25 | ||||||
|  |     var series = this.db.series.filter(se => se.name.toLowerCase().includes(q)) | ||||||
|  |     series = series.slice(0, limit) | ||||||
|  |     res.json(series) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | module.exports = new SeriesController() | ||||||
| @ -2,6 +2,7 @@ const Logger = require('../Logger') | |||||||
| const LibraryFile = require('./files/LibraryFile') | const LibraryFile = require('./files/LibraryFile') | ||||||
| const Book = require('./entities/Book') | const Book = require('./entities/Book') | ||||||
| const Podcast = require('./entities/Podcast') | const Podcast = require('./entities/Podcast') | ||||||
|  | const { areEquivalent, copyValue } = require('../utils/index') | ||||||
| 
 | 
 | ||||||
| class LibraryItem { | class LibraryItem { | ||||||
|   constructor(libraryItem = null) { |   constructor(libraryItem = null) { | ||||||
| @ -132,5 +133,23 @@ class LibraryItem { | |||||||
|     this.libraryFiles.forEach((lf) => total += lf.metadata.size) |     this.libraryFiles.forEach((lf) => total += lf.metadata.size) | ||||||
|     return total |     return total | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var json = this.toJSON() | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in json) { | ||||||
|  |       if (payload[key] !== undefined) { | ||||||
|  |         if (key === 'media') { | ||||||
|  |           if (this.media.update(payload[key])) { | ||||||
|  |             hasUpdates = true | ||||||
|  |           } | ||||||
|  |         } else if (!areEquivalent(payload[key], json[key])) { | ||||||
|  |           this[key] = copyValue(payload[key]) | ||||||
|  |           hasUpdates = true | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = LibraryItem | module.exports = LibraryItem | ||||||
| @ -1,6 +1,8 @@ | |||||||
|  | const Logger = require('../../Logger') | ||||||
| const BookMetadata = require('../metadata/BookMetadata') | const BookMetadata = require('../metadata/BookMetadata') | ||||||
| const AudioFile = require('../files/AudioFile') | const AudioFile = require('../files/AudioFile') | ||||||
| const EBookFile = require('../files/EBookFile') | const EBookFile = require('../files/EBookFile') | ||||||
|  | const { areEquivalent, copyValue } = require('../../utils/index') | ||||||
| 
 | 
 | ||||||
| class Book { | class Book { | ||||||
|   constructor(book) { |   constructor(book) { | ||||||
| @ -78,5 +80,24 @@ class Book { | |||||||
|     this.audioFiles.forEach((af) => total += af.metadata.size) |     this.audioFiles.forEach((af) => total += af.metadata.size) | ||||||
|     return total |     return total | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var json = this.toJSON() | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in json) { | ||||||
|  |       if (payload[key] !== undefined) { | ||||||
|  |         if (key === 'metadata') { | ||||||
|  |           if (this.metadata.update(payload.metadata)) { | ||||||
|  |             hasUpdates = true | ||||||
|  |           } | ||||||
|  |         } else if (!areEquivalent(payload[key], json[key])) { | ||||||
|  |           this[key] = copyValue(payload[key]) | ||||||
|  |           Logger.debug('[Book] Key updated', key, this[key]) | ||||||
|  |           hasUpdates = true | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = Book | module.exports = Book | ||||||
| @ -1,3 +1,6 @@ | |||||||
|  | const Logger = require('../../Logger') | ||||||
|  | const { areEquivalent, copyValue } = require('../../utils/index') | ||||||
|  | 
 | ||||||
| class BookMetadata { | class BookMetadata { | ||||||
|   constructor(metadata) { |   constructor(metadata) { | ||||||
|     this.title = null |     this.title = null | ||||||
| @ -100,5 +103,20 @@ class BookMetadata { | |||||||
|   hasNarrator(narratorName) { |   hasNarrator(narratorName) { | ||||||
|     return this.narrators.includes(narratorName) |     return this.narrators.includes(narratorName) | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var json = this.toJSON() | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in json) { | ||||||
|  |       if (payload[key] !== undefined) { | ||||||
|  |         if (!areEquivalent(payload[key], json[key])) { | ||||||
|  |           this[key] = copyValue(payload[key]) | ||||||
|  |           Logger.debug('[BookMetadata] Key updated', key, this[key]) | ||||||
|  |           hasUpdates = true | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = BookMetadata | module.exports = BookMetadata | ||||||
							
								
								
									
										142
									
								
								server/utils/areEquivalent.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								server/utils/areEquivalent.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,142 @@ | |||||||
|  | /** | ||||||
|  |  * https://gist.github.com/DLiblik/96801665f9b6c935f12c1071d37eae95
 | ||||||
|  |  Compares two items (values or references) for nested equivalency, meaning that | ||||||
|  |  at root and at each key or index they are equivalent as follows: | ||||||
|  |  - If a value type, values are either hard equal (===) or are both NaN | ||||||
|  |      (different than JS where NaN !== NaN) | ||||||
|  |  - If functions, they are the same function instance or have the same value | ||||||
|  |      when converted to string via `toString()` | ||||||
|  |  - If Date objects, both have the same getTime() or are both NaN (invalid) | ||||||
|  |  - If arrays, both are same length, and all contained values areEquivalent | ||||||
|  |      recursively - only contents by numeric key are checked | ||||||
|  |  - If other object types, enumerable keys are the same (the keys themselves) | ||||||
|  |      and values at every key areEquivalent recursively | ||||||
|  |  Author: Dathan Liblik | ||||||
|  |  License: Free to use anywhere by anyone, as-is, no guarantees of any kind. | ||||||
|  |  @param value1 First item to compare | ||||||
|  |  @param value2 Other item to compare | ||||||
|  |  @param stack Used internally to track circular refs - don't set it | ||||||
|  |  */ | ||||||
|  | module.exports = function areEquivalent(value1, value2, stack = []) { | ||||||
|  |   // Numbers, strings, null, undefined, symbols, functions, booleans.
 | ||||||
|  |   // Also: objects (incl. arrays) that are actually the same instance
 | ||||||
|  |   if (value1 === value2) { | ||||||
|  |     // Fast and done
 | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const type1 = typeof value1; | ||||||
|  | 
 | ||||||
|  |   // Ensure types match
 | ||||||
|  |   if (type1 !== typeof value2) { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Special case for number: check for NaN on both sides
 | ||||||
|  |   // (only way they can still be equivalent but not equal)
 | ||||||
|  |   if (type1 === 'number') { | ||||||
|  |     // Failed initial equals test, but could still both be NaN
 | ||||||
|  |     return (isNaN(value1) && isNaN(value2)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Special case for function: check for toString() equivalence
 | ||||||
|  |   if (type1 === 'function') { | ||||||
|  |     // Failed initial equals test, but could still have equivalent
 | ||||||
|  |     // implementations - note, will match on functions that have same name
 | ||||||
|  |     // and are native code: `function abc() { [native code] }`
 | ||||||
|  |     return value1.toString() === value2.toString(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // For these types, cannot still be equal at this point, so fast-fail
 | ||||||
|  |   if (type1 === 'bigint' || type1 === 'boolean' || | ||||||
|  |     type1 === 'function' || type1 === 'string' || | ||||||
|  |     type1 === 'symbol') { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // For dates, cast to number and ensure equal or both NaN (note, if same
 | ||||||
|  |   // exact instance then we're not here - that was checked above)
 | ||||||
|  |   if (value1 instanceof Date) { | ||||||
|  |     if (!(value2 instanceof Date)) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |     // Convert to number to compare
 | ||||||
|  |     const asNum1 = +value1, asNum2 = +value2; | ||||||
|  |     // Check if both invalid (NaN) or are same value
 | ||||||
|  |     return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // At this point, it's a reference type and could be circular, so
 | ||||||
|  |   // make sure we haven't been here before... note we only need to track value1
 | ||||||
|  |   // since value1 being un-circular means value2 will either be equal (and not
 | ||||||
|  |   // circular too) or unequal whether circular or not.
 | ||||||
|  |   if (stack.includes(value1)) { | ||||||
|  |     throw new Error(`areEquivalent value1 is circular`); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // breadcrumb
 | ||||||
|  |   stack.push(value1); | ||||||
|  | 
 | ||||||
|  |   // Handle arrays
 | ||||||
|  |   if (Array.isArray(value1)) { | ||||||
|  |     if (!Array.isArray(value2)) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const length = value1.length; | ||||||
|  | 
 | ||||||
|  |     if (length !== value2.length) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     for (let i = 0; i < length; i++) { | ||||||
|  |       if (!areEquivalent(value1[i], value2[i], stack)) { | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Final case: object
 | ||||||
|  | 
 | ||||||
|  |   // get both key lists and check length
 | ||||||
|  |   const keys1 = Object.keys(value1); | ||||||
|  |   const keys2 = Object.keys(value2); | ||||||
|  |   const numKeys = keys1.length; | ||||||
|  | 
 | ||||||
|  |   if (keys2.length !== numKeys) { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Empty object on both sides?
 | ||||||
|  |   if (numKeys === 0) { | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // sort is a native call so it's very fast - much faster than comparing the
 | ||||||
|  |   // values at each key if it can be avoided, so do the sort and then
 | ||||||
|  |   // ensure every key matches at every index
 | ||||||
|  |   keys1.sort(); | ||||||
|  |   keys2.sort(); | ||||||
|  | 
 | ||||||
|  |   // Ensure perfect match across all keys
 | ||||||
|  |   for (let i = 0; i < numKeys; i++) { | ||||||
|  |     if (keys1[i] !== keys2[i]) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Ensure perfect match across all values
 | ||||||
|  |   for (let i = 0; i < numKeys; i++) { | ||||||
|  |     if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], stack)) { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // back up
 | ||||||
|  |   stack.pop(); | ||||||
|  | 
 | ||||||
|  |   // Walk the same, talk the same - matching ducks. Quack.
 | ||||||
|  |   // 🦆🦆
 | ||||||
|  |   return true; | ||||||
|  | } | ||||||
| @ -2,6 +2,7 @@ const Path = require('path') | |||||||
| const fs = require('fs') | const fs = require('fs') | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const { parseString } = require("xml2js") | const { parseString } = require("xml2js") | ||||||
|  | const areEquivalent = require('./areEquivalent') | ||||||
| 
 | 
 | ||||||
| const levenshteinDistance = (str1, str2, caseSensitive = false) => { | const levenshteinDistance = (str1, str2, caseSensitive = false) => { | ||||||
|   if (!caseSensitive) { |   if (!caseSensitive) { | ||||||
| @ -100,3 +101,20 @@ module.exports.reqSupportsWebp = (req) => { | |||||||
|   if (!req || !req.headers || !req.headers.accept) return false |   if (!req || !req.headers || !req.headers.accept) return false | ||||||
|   return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*' |   return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*' | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | module.exports.areEquivalent = areEquivalent | ||||||
|  | 
 | ||||||
|  | module.exports.copyValue = (val) => { | ||||||
|  |   if (!val) return null | ||||||
|  |   if (!this.isObject(val)) return val | ||||||
|  | 
 | ||||||
|  |   if (Array.isArray(val)) { | ||||||
|  |     return val.map(this.copyValue) | ||||||
|  |   } else { | ||||||
|  |     var final = {} | ||||||
|  |     for (const key in val) { | ||||||
|  |       final[key] = this.copyValue(val[key]) | ||||||
|  |     } | ||||||
|  |     return final | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -97,12 +97,12 @@ module.exports = { | |||||||
|       var mediaMetadata = li.media.metadata |       var mediaMetadata = li.media.metadata | ||||||
|       if (mediaMetadata.authors.length) { |       if (mediaMetadata.authors.length) { | ||||||
|         mediaMetadata.authors.forEach((author) => { |         mediaMetadata.authors.forEach((author) => { | ||||||
|           if (author && !data.authors.includes(author.name)) data.authors.push(author.name) |           if (author && !data.authors.find(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name }) | ||||||
|         }) |         }) | ||||||
|       } |       } | ||||||
|       if (mediaMetadata.series.length) { |       if (mediaMetadata.series.length) { | ||||||
|         mediaMetadata.series.forEach((series) => { |         mediaMetadata.series.forEach((series) => { | ||||||
|           if (series && !data.series.includes(series.name)) data.series.push(series.name) |           if (series && !data.series.find(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name }) | ||||||
|         }) |         }) | ||||||
|       } |       } | ||||||
|       if (mediaMetadata.genres.length) { |       if (mediaMetadata.genres.length) { | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user