mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	New data model batch routes and batch editor
This commit is contained in:
		
							parent
							
								
									6597fca576
								
							
						
					
					
						commit
						4bdef893af
					
				| @ -44,8 +44,8 @@ | ||||
|         </nuxt-link> | ||||
|       </div> | ||||
| 
 | ||||
|       <div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> | ||||
|         <h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1> | ||||
|       <div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> | ||||
|         <h1 class="text-2xl px-4">{{ numLibraryItemsSelected }} Selected</h1> | ||||
|         <div class="flex-grow" /> | ||||
|         <ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom"> | ||||
|           <ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" /> | ||||
| @ -53,7 +53,7 @@ | ||||
|         <ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom"> | ||||
|           <ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" /> | ||||
|         </ui-tooltip> | ||||
|         <template v-if="userCanUpdate && numAudiobooksSelected < 50"> | ||||
|         <template v-if="userCanUpdate && numLibraryItemsSelected < 50"> | ||||
|           <ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> | ||||
|         </template> | ||||
|         <ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" /> | ||||
| @ -94,11 +94,11 @@ export default { | ||||
|     username() { | ||||
|       return this.user ? this.user.username : 'err' | ||||
|     }, | ||||
|     numAudiobooksSelected() { | ||||
|       return this.selectedAudiobooks.length | ||||
|     numLibraryItemsSelected() { | ||||
|       return this.selectedLibraryItems.length | ||||
|     }, | ||||
|     selectedAudiobooks() { | ||||
|       return this.$store.state.selectedAudiobooks | ||||
|     selectedLibraryItems() { | ||||
|       return this.$store.state.selectedLibraryItems | ||||
|     }, | ||||
|     userAudiobooks() { | ||||
|       return this.$store.state.user.user.audiobooks || {} | ||||
| @ -117,8 +117,8 @@ export default { | ||||
|     }, | ||||
|     selectedIsRead() { | ||||
|       // Find an audiobook that is not read, if none then all audiobooks read | ||||
|       return !this.selectedAudiobooks.find((ab) => { | ||||
|         var userAb = this.userAudiobooks[ab] | ||||
|       return !this.selectedLibraryItems.find((li) => { | ||||
|         var userAb = this.userAudiobooks[li] | ||||
|         return !userAb || !userAb.isRead | ||||
|       }) | ||||
|     }, | ||||
| @ -150,16 +150,16 @@ export default { | ||||
|     }, | ||||
|     cancelSelectionMode() { | ||||
|       if (this.processingBatchDelete) return | ||||
|       this.$store.commit('setSelectedAudiobooks', []) | ||||
|       this.$store.commit('setSelectedLibraryItems', []) | ||||
|       this.$eventBus.$emit('bookshelf-clear-selection') | ||||
|       this.isAllSelected = false | ||||
|     }, | ||||
|     toggleBatchRead() { | ||||
|       this.$store.commit('setProcessingBatch', true) | ||||
|       var newIsRead = !this.selectedIsRead | ||||
|       var updateProgressPayloads = this.selectedAudiobooks.map((ab) => { | ||||
|       var updateProgressPayloads = this.selectedLibraryItems.map((lid) => { | ||||
|         return { | ||||
|           audiobookId: ab, | ||||
|           audiobookId: lid, | ||||
|           isRead: newIsRead | ||||
|         } | ||||
|       }) | ||||
| @ -168,7 +168,7 @@ export default { | ||||
|         .then(() => { | ||||
|           this.$toast.success('Batch update success!') | ||||
|           this.$store.commit('setProcessingBatch', false) | ||||
|           this.$store.commit('setSelectedAudiobooks', []) | ||||
|           this.$store.commit('setSelectedLibraryItems', []) | ||||
|           this.$eventBus.$emit('bookshelf-clear-selection') | ||||
|         }) | ||||
|         .catch((error) => { | ||||
| @ -178,20 +178,20 @@ export default { | ||||
|         }) | ||||
|     }, | ||||
|     batchDeleteClick() { | ||||
|       var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook' | ||||
|       var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} audiobooks` : 'this audiobook' | ||||
|       var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf` | ||||
|       if (confirm(confirmMsg)) { | ||||
|         this.processingBatchDelete = true | ||||
|         this.$store.commit('setProcessingBatch', true) | ||||
|         this.$axios | ||||
|           .$post(`/api/books/batch/delete`, { | ||||
|             audiobookIds: this.selectedAudiobooks | ||||
|           .$post(`/api/items/batch/delete`, { | ||||
|             libraryItemIds: this.selectedLibraryItems | ||||
|           }) | ||||
|           .then(() => { | ||||
|             this.$toast.success('Batch delete success!') | ||||
|             this.processingBatchDelete = false | ||||
|             this.$store.commit('setProcessingBatch', false) | ||||
|             this.$store.commit('setSelectedAudiobooks', []) | ||||
|             this.$store.commit('setSelectedLibraryItems', []) | ||||
|             this.$eventBus.$emit('bookshelf-clear-selection') | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|  | ||||
| @ -69,16 +69,16 @@ export default { | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     audiobookId() { | ||||
|     libraryItemId() { | ||||
|       return this.book.id | ||||
|     }, | ||||
|     selected: { | ||||
|       get() { | ||||
|         return this.$store.getters['getIsAudiobookSelected'](this.audiobookId) | ||||
|         return this.$store.getters['getIsLibraryItemSelected'](this.libraryItemId) | ||||
|       }, | ||||
|       set(val) { | ||||
|         if (this.processingBatch) return | ||||
|         this.$store.commit('setAudiobookSelected', { audiobookId: this.audiobookId, selected: val }) | ||||
|         this.$store.commit('setLibraryItemSelected', { libraryItemId: this.libraryItemId, selected: val }) | ||||
|       } | ||||
|     }, | ||||
|     processingBatch() { | ||||
| @ -118,7 +118,7 @@ export default { | ||||
|       return this.book.numTracks | ||||
|     }, | ||||
|     isStreaming() { | ||||
|       return this.$store.getters['getLibraryItemIdStreaming'] === this.audiobookId | ||||
|       return this.$store.getters['getLibraryItemIdStreaming'] === this.libraryItemId | ||||
|     }, | ||||
|     showReadButton() { | ||||
|       return this.showExperimentalFeatures && this.numEbooks | ||||
| @ -142,7 +142,7 @@ export default { | ||||
|   methods: { | ||||
|     selectBtnClick() { | ||||
|       if (this.processingBatch) return | ||||
|       this.$store.commit('toggleAudiobookSelected', this.audiobookId) | ||||
|       this.$store.commit('toggleLibraryItemSelected', this.libraryItemId) | ||||
|     }, | ||||
|     openEbook() { | ||||
|       this.$store.commit('showEReader', this.book) | ||||
| @ -156,7 +156,7 @@ export default { | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
|       <div class="w-full h-full pt-6"> | ||||
|         <div v-if="shelf.type === 'books'" class="flex items-center"> | ||||
|           <template v-for="(entity, index) in shelf.entities"> | ||||
|             <cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectBook" @edit="editBook" /> | ||||
|             <cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" /> | ||||
|           </template> | ||||
|         </div> | ||||
|         <div v-if="shelf.type === 'series'" class="flex items-center"> | ||||
| @ -90,7 +90,7 @@ export default { | ||||
|       return this.$store.state.libraries.currentLibraryId | ||||
|     }, | ||||
|     isSelectionMode() { | ||||
|       return this.$store.getters['getNumAudiobooksSelected'] > 0 | ||||
|       return this.$store.getters['getNumLibraryItemsSelected'] > 0 | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
| @ -100,19 +100,19 @@ export default { | ||||
|       this.$store.commit('showEditModal', audiobook) | ||||
|     }, | ||||
|     updateSelectionMode(val) { | ||||
|       var selectedAudiobooks = this.$store.state.selectedAudiobooks | ||||
|       var selectedLibraryItems = this.$store.state.selectedLibraryItems | ||||
|       if (this.shelf.type === 'books') { | ||||
|         this.shelf.entities.forEach((ent) => { | ||||
|           var component = this.$refs[`shelf-book-${ent.id}`] | ||||
|           if (!component || !component.length) return | ||||
|           component = component[0] | ||||
|           component.setSelectionMode(val) | ||||
|           component.selected = selectedAudiobooks.includes(ent.id) | ||||
|           component.selected = selectedLibraryItems.includes(ent.id) | ||||
|         }) | ||||
|       } | ||||
|     }, | ||||
|     selectBook(audiobook) { | ||||
|       this.$store.commit('toggleAudiobookSelected', audiobook.id) | ||||
|     selectItem(libraryItem) { | ||||
|       this.$store.commit('toggleLibraryItemSelected', libraryItem.id) | ||||
|     }, | ||||
|     scrolled() { | ||||
|       clearTimeout(this.scrollTimer) | ||||
|  | ||||
| @ -183,8 +183,8 @@ export default { | ||||
|       // Includes margin | ||||
|       return this.entityWidth + 24 | ||||
|     }, | ||||
|     selectedAudiobooks() { | ||||
|       return this.$store.state.selectedAudiobooks || [] | ||||
|     selectedLibraryItems() { | ||||
|       return this.$store.state.selectedLibraryItems || [] | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 | ||||
| @ -214,9 +214,9 @@ export default { | ||||
|     }, | ||||
|     selectEntity(entity) { | ||||
|       if (this.entityName === 'books' || this.entityName === 'series-books') { | ||||
|         this.$store.commit('toggleAudiobookSelected', entity.id) | ||||
|         this.$store.commit('toggleLibraryItemSelected', entity.id) | ||||
| 
 | ||||
|         var newIsSelectionMode = !!this.selectedAudiobooks.length | ||||
|         var newIsSelectionMode = !!this.selectedLibraryItems.length | ||||
|         if (this.isSelectionMode !== newIsSelectionMode) { | ||||
|           this.isSelectionMode = newIsSelectionMode | ||||
|           this.updateBookSelectionMode(newIsSelectionMode) | ||||
|  | ||||
| @ -101,7 +101,7 @@ export default { | ||||
|       return this.$store.state.globals.showBatchUserCollectionModal | ||||
|     }, | ||||
|     selectedBookIds() { | ||||
|       return this.$store.state.selectedAudiobooks || [] | ||||
|       return this.$store.state.selectedLibraryItems || [] | ||||
|     }, | ||||
|     currentLibraryId() { | ||||
|       return this.$store.state.libraries.currentLibraryId | ||||
|  | ||||
| @ -1,106 +1,27 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full relative"> | ||||
|     <form class="w-full h-full" @submit.prevent="submitForm"> | ||||
|       <div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto"> | ||||
|         <div class="flex -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.title" label="Title" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.subtitle" label="Subtitle" /> | ||||
|           </div> | ||||
|         </div> | ||||
|     <widgets-item-details-edit ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-3/4 px-1"> | ||||
|             <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> | ||||
|           </div> | ||||
|         </div> | ||||
|     <div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'"> | ||||
|       <div class="flex items-center px-4"> | ||||
|         <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <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 class="flex-grow" /> | ||||
| 
 | ||||
|         <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> | ||||
|         <ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block"> | ||||
|           <ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn> | ||||
|         </ui-tooltip> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> | ||||
|           <ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> | ||||
|         </ui-tooltip> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/3 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.narrator" label="Narrator" /> | ||||
|           </div> | ||||
|           <div class="w-1/3 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.publisher" label="Publisher" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.language" label="Language" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> | ||||
|           <ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn> | ||||
|         </ui-tooltip> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/3 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.isbn" label="ISBN" /> | ||||
|           </div> | ||||
|           <div class="w-1/3 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.asin" label="ASIN" /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <ui-btn @click="submitForm">Submit</ui-btn> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'"> | ||||
|         <div class="flex items-center px-4"> | ||||
|           <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> | ||||
| 
 | ||||
|           <div class="flex-grow" /> | ||||
| 
 | ||||
|           <ui-tooltip v-if="!isMissing" text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="mr-4 hidden sm:block"> | ||||
|             <ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn> | ||||
|           </ui-tooltip> | ||||
| 
 | ||||
|           <ui-tooltip :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-4"> | ||||
|             <ui-btn v-if="isRootUser" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn> | ||||
|           </ui-tooltip> | ||||
| 
 | ||||
|           <ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-4"> | ||||
|             <ui-btn v-if="isRootUser" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn> | ||||
|           </ui-tooltip> | ||||
| 
 | ||||
|           <ui-btn type="submit">Submit</ui-btn> | ||||
|         </div> | ||||
|       </div> | ||||
|     </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> | ||||
| </template> | ||||
| @ -116,23 +37,6 @@ export default { | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       selectedSeries: {}, | ||||
|       showSeriesForm: false, | ||||
|       details: { | ||||
|         title: null, | ||||
|         subtitle: null, | ||||
|         description: null, | ||||
|         author: null, | ||||
|         narrator: null, | ||||
|         series: null, | ||||
|         publishYear: null, | ||||
|         publisher: null, | ||||
|         language: null, | ||||
|         isbn: null, | ||||
|         asin: null, | ||||
|         genres: [] | ||||
|       }, | ||||
|       newTags: [], | ||||
|       resettingProgress: false, | ||||
|       isScrollable: false, | ||||
|       savingMetadata: false, | ||||
| @ -140,14 +44,6 @@ export default { | ||||
|       quickMatching: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isProcessing: { | ||||
|       get() { | ||||
| @ -175,18 +71,6 @@ export default { | ||||
|     userCanDelete() { | ||||
|       return this.$store.getters['user/getUserCanDelete'] | ||||
|     }, | ||||
|     genres() { | ||||
|       return this.filterData.genres || [] | ||||
|     }, | ||||
|     tags() { | ||||
|       return this.filterData.tags || [] | ||||
|     }, | ||||
|     series() { | ||||
|       return this.filterData.series || [] | ||||
|     }, | ||||
|     filterData() { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     }, | ||||
|     libraryId() { | ||||
|       return this.libraryItem ? this.libraryItem.libraryId : null | ||||
|     }, | ||||
| @ -196,71 +80,9 @@ export default { | ||||
|     libraryScan() { | ||||
|       if (!this.libraryId) return null | ||||
|       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: { | ||||
|     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() { | ||||
|       this.quickMatching = true | ||||
|       var matchOptions = { | ||||
| @ -326,25 +148,20 @@ export default { | ||||
|       if (this.isProcessing) { | ||||
|         return | ||||
|       } | ||||
|       this.isProcessing = true | ||||
|       if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) { | ||||
|         this.$refs.authorsSelect.forceBlur() | ||||
|       if (!this.$refs.itemDetailsEdit) { | ||||
|         return | ||||
|       } | ||||
|       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { | ||||
|         this.$refs.genresSelect.forceBlur() | ||||
|       var updatedDetails = this.$refs.itemDetailsEdit.getDetails() | ||||
|       if (!updatedDetails.hasChanges) { | ||||
|         this.$toast.info('No changes were made') | ||||
|         return | ||||
|       } | ||||
|       if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) { | ||||
|         this.$refs.tagsSelect.forceBlur() | ||||
|       } | ||||
|       this.$nextTick(this.handleForm) | ||||
|       this.updateDetails(updatedDetails) | ||||
|     }, | ||||
|     async handleForm() { | ||||
|       const updatePayload = { | ||||
|         metadata: this.details, | ||||
|         tags: this.newTags | ||||
|       } | ||||
|       console.log('Sending update', updatePayload) | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { | ||||
|     async updateDetails(updatedDetails) { | ||||
|       this.isProcessing = true | ||||
|       console.log('Sending update', updatedDetails.updatePayload) | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatedDetails.updatePayload).catch((error) => { | ||||
|         console.error('Failed to update', error) | ||||
|         return false | ||||
|       }) | ||||
| @ -354,29 +171,6 @@ export default { | ||||
|         this.$emit('close') | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.details.title = this.mediaMetadata.title | ||||
|       this.details.subtitle = this.mediaMetadata.subtitle | ||||
|       this.details.description = this.mediaMetadata.description | ||||
|       this.$set( | ||||
|         this.details, | ||||
|         'authors', | ||||
|         (this.mediaMetadata.authors || []).map((se) => ({ ...se })) | ||||
|       ) | ||||
|       this.details.narrator = this.mediaMetadata.narrator | ||||
|       this.details.genres = [...(this.mediaMetadata.genres || [])] | ||||
|       this.$set( | ||||
|         this.details, | ||||
|         'series', | ||||
|         (this.mediaMetadata.series || []).map((se) => ({ ...se })) | ||||
|       ) | ||||
|       this.details.publishYear = this.mediaMetadata.publishYear | ||||
|       this.details.publisher = this.mediaMetadata.publisher || null | ||||
|       this.details.language = this.mediaMetadata.language || null | ||||
|       this.details.isbn = this.mediaMetadata.isbn || null | ||||
|       this.details.asin = this.mediaMetadata.asin || null | ||||
|       this.newTags = [...(this.media.tags || [])] | ||||
|     }, | ||||
|     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`)) { | ||||
|         this.isProcessing = true | ||||
| @ -396,8 +190,9 @@ export default { | ||||
|     }, | ||||
|     checkIsScrollable() { | ||||
|       this.$nextTick(() => { | ||||
|         if (this.$refs.formWrapper) { | ||||
|           if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) { | ||||
|         var formWrapper = document.getElementById('formWrapper') | ||||
|         if (formWrapper) { | ||||
|           if (formWrapper.scrollHeight > formWrapper.clientHeight) { | ||||
|             this.isScrollable = true | ||||
|           } else { | ||||
|             this.isScrollable = false | ||||
| @ -407,12 +202,15 @@ export default { | ||||
|     }, | ||||
|     setResizeObserver() { | ||||
|       try { | ||||
|         this.$nextTick(() => { | ||||
|           const resizeObserver = new ResizeObserver(() => { | ||||
|             this.checkIsScrollable() | ||||
|         var formWrapper = document.getElementById('formWrapper') | ||||
|         if (formWrapper) { | ||||
|           this.$nextTick(() => { | ||||
|             const resizeObserver = new ResizeObserver(() => { | ||||
|               this.checkIsScrollable() | ||||
|             }) | ||||
|             resizeObserver.observe(formWrapper) | ||||
|           }) | ||||
|           resizeObserver.observe(this.$refs.formWrapper) | ||||
|         }) | ||||
|         } | ||||
|       } catch (error) { | ||||
|         console.error('Failed to set resize observer') | ||||
|       } | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| <template> | ||||
|   <label class="flex justify-start items-center cursor-pointer"> | ||||
|     <div class="border-2 rounded border-gray-400 flex flex-shrink-0 justify-center items-center" :class="wrapperClass"> | ||||
|       <input v-model="selected" type="checkbox" class="opacity-0 absolute cursor-pointer" /> | ||||
|   <label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''"> | ||||
|     <div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass"> | ||||
|       <input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" /> | ||||
|       <svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> | ||||
|     </div> | ||||
|     <div v-if="label" class="select-none pl-1 text-gray-100" :class="labelClass">{{ label }}</div> | ||||
|     <div v-if="label" class="select-none text-gray-100" :class="labelClassname">{{ label }}</div> | ||||
|   </label> | ||||
| </template> | ||||
| 
 | ||||
| @ -18,10 +18,19 @@ export default { | ||||
|       type: String, | ||||
|       default: 'white' | ||||
|     }, | ||||
|     borderColor: { | ||||
|       type: String, | ||||
|       default: 'gray-400' | ||||
|     }, | ||||
|     checkColor: { | ||||
|       type: String, | ||||
|       default: 'green-500' | ||||
|     } | ||||
|     }, | ||||
|     labelClass: { | ||||
|       type: String, | ||||
|       default: '' | ||||
|     }, | ||||
|     disabled: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return {} | ||||
| @ -36,15 +45,17 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     wrapperClass() { | ||||
|       var classes = [`bg-${this.checkboxBg}`] | ||||
|       var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`] | ||||
|       if (this.small) classes.push('w-4 h-4') | ||||
|       else classes.push('w-6 h-6') | ||||
| 
 | ||||
|       return classes.join(' ') | ||||
|     }, | ||||
|     labelClass() { | ||||
|       if (this.small) return 'text-xs md:text-sm' | ||||
|       return '' | ||||
|     labelClassname() { | ||||
|       if (this.labelClass) return this.labelClass | ||||
|       var classes = ['pl-1'] | ||||
|       if (this.small) classes.push('text-xs md:text-sm') | ||||
|       return classes.join(' ') | ||||
|     }, | ||||
|     svgClass() { | ||||
|       var classes = [`text-${this.checkColor}`] | ||||
|  | ||||
| @ -8,7 +8,7 @@ | ||||
|         </div> | ||||
|       </form> | ||||
| 
 | ||||
|       <ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || 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"> | ||||
|       <ul ref="menu" v-show="isFocused && itemsToShow.length" 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 itemsToShow"> | ||||
|           <li :key="item" 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"> | ||||
| @ -47,7 +47,7 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       isFocused: false, | ||||
|       currentSearch: null, | ||||
|       // currentSearch: null, | ||||
|       typingTimeout: null, | ||||
|       textInput: null | ||||
|     } | ||||
| @ -70,12 +70,13 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     itemsToShow() { | ||||
|       if (!this.currentSearch || !this.textInput || this.textInput === this.input) { | ||||
|         return this.items | ||||
|       if (!this.editable) return this.items | ||||
|       if (!this.textInput || this.textInput === this.input) { | ||||
|         return [] | ||||
|       } | ||||
|       return this.items.filter((i) => { | ||||
|         var iValue = String(i).toLowerCase() | ||||
|         return iValue.includes(this.currentSearch.toLowerCase()) | ||||
|         return iValue.includes(this.textInput.toLowerCase()) | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
| @ -83,7 +84,7 @@ export default { | ||||
|     keydownInput() { | ||||
|       clearTimeout(this.typingTimeout) | ||||
|       this.typingTimeout = setTimeout(() => { | ||||
|         this.currentSearch = this.textInput | ||||
|         // this.currentSearch = this.textInput | ||||
|       }, 100) | ||||
|     }, | ||||
|     inputFocus() { | ||||
| @ -127,11 +128,11 @@ export default { | ||||
|       if (val && !this.items.includes(val)) { | ||||
|         this.$emit('newItem', val) | ||||
|       } | ||||
|       this.currentSearch = null | ||||
|       // this.currentSearch = null | ||||
|     }, | ||||
|     clickedOption(e, item) { | ||||
|       this.textInput = null | ||||
|       this.currentSearch = null | ||||
|       // this.currentSearch = null | ||||
|       this.input = item | ||||
|       if (this.$refs.input) this.$refs.input.blur() | ||||
|     } | ||||
|  | ||||
| @ -70,7 +70,7 @@ export default { | ||||
|   computed: { | ||||
|     selected: { | ||||
|       get() { | ||||
|         return this.value | ||||
|         return this.value || [] | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('input', val) | ||||
|  | ||||
| @ -82,6 +82,9 @@ export default { | ||||
|         this.$emit('input', val) | ||||
|       } | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     wrapperClass() { | ||||
|       var classes = [] | ||||
|       if (this.disabled) classes.push('bg-black-300') | ||||
| @ -110,7 +113,7 @@ export default { | ||||
|       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) => { | ||||
|       var results = await this.$axios.$get(`/api/${this.endpoint}?q=${this.currentSearch}&limit=15&token=${this.userToken}`).catch((error) => { | ||||
|         console.error('Failed to get search results', error) | ||||
|         return [] | ||||
|       }) | ||||
|  | ||||
							
								
								
									
										334
									
								
								client/components/widgets/ItemDetailsEdit.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										334
									
								
								client/components/widgets/ItemDetailsEdit.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,334 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full relative"> | ||||
|     <form class="w-full h-full" @submit.prevent="submitForm"> | ||||
|       <div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto"> | ||||
|         <div class="flex -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.title" label="Title" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.subtitle" label="Subtitle" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-3/4 px-1"> | ||||
|             <!-- Authors filter only contains authors in this library, use query input to query all authors --> | ||||
|             <ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" /> | ||||
|           </div> | ||||
|           <div class="w-1/4 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.isbn" label="ISBN" /> | ||||
|           </div> | ||||
|           <div class="w-1/4 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.asin" label="ASIN" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-1/2 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.publisher" label="Publisher" /> | ||||
|           </div> | ||||
|           <div class="w-1/4 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.language" label="Language" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1 pt-6"> | ||||
|             <div class="flex justify-center"> | ||||
|               <ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </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> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       selectedSeries: {}, | ||||
|       showSeriesForm: false, | ||||
|       details: { | ||||
|         title: null, | ||||
|         subtitle: null, | ||||
|         description: null, | ||||
|         authors: [], | ||||
|         narrators: [], | ||||
|         series: [], | ||||
|         publishYear: null, | ||||
|         publisher: null, | ||||
|         language: null, | ||||
|         isbn: null, | ||||
|         asin: null, | ||||
|         genres: [], | ||||
|         explicit: false | ||||
|       }, | ||||
|       newTags: [] | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     genres() { | ||||
|       return this.filterData.genres || [] | ||||
|     }, | ||||
|     tags() { | ||||
|       return this.filterData.tags || [] | ||||
|     }, | ||||
|     series() { | ||||
|       return this.filterData.series || [] | ||||
|     }, | ||||
|     narrators() { | ||||
|       return this.filterData.narrators || [] | ||||
|     }, | ||||
|     filterData() { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     }, | ||||
|     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: { | ||||
|     getDetails() { | ||||
|       this.forceBlur() | ||||
|       return this.checkForChanges() | ||||
|     }, | ||||
|     mapBatchDetails(batchDetails) { | ||||
|       for (const key in batchDetails) { | ||||
|         if (key === 'tags') { | ||||
|           this.newTags = [...batchDetails.tags] | ||||
|         } else if (key === 'genres' || key === 'narrators') { | ||||
|           this.details[key] = [...batchDetails[key]] | ||||
|         } else if (key === 'authors' || key === 'series') { | ||||
|           this.details[key] = batchDetails[key].map((i) => ({ ...i })) | ||||
|         } else { | ||||
|           this.details[key] = batchDetails[key] | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     forceBlur() { | ||||
|       if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) { | ||||
|         this.$refs.authorsSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) { | ||||
|         this.$refs.narratorsSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { | ||||
|         this.$refs.genresSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) { | ||||
|         this.$refs.tagsSelect.forceBlur() | ||||
|       } | ||||
|     }, | ||||
|     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 | ||||
|     }, | ||||
|     addNewSeries() { | ||||
|       this.selectedSeries = { | ||||
|         id: `new-${Date.now()}`, | ||||
|         name: '', | ||||
|         sequence: '' | ||||
|       } | ||||
|       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 | ||||
|     }, | ||||
|     stringArrayEqual(array1, array2) { | ||||
|       // return false if different | ||||
|       if (array1.length !== array2.length) return false | ||||
|       for (var item of array1) { | ||||
|         if (!array2.includes(item)) return false | ||||
|       } | ||||
|       return true | ||||
|     }, | ||||
|     objectArrayEqual(array1, array2) { | ||||
|       const isIterable = (value) => { | ||||
|         return Symbol.iterator in Object(value) | ||||
|       } | ||||
|       if (!isIterable(array1) || !isIterable(array2)) { | ||||
|         console.error(array1, array2) | ||||
|         throw new Error('Invalid arrays passed in') | ||||
|       } | ||||
| 
 | ||||
|       // array of objects with id key | ||||
|       if (array1.length !== array2.length) return false | ||||
| 
 | ||||
|       for (var item of array1) { | ||||
|         var matchingItem = array2.find((a) => a.id === item.id) | ||||
|         if (!matchingItem) return false | ||||
|         for (var key in item) { | ||||
|           if (item[key] !== matchingItem[key]) { | ||||
|             console.log('Object array item keys changed', key, item[key], matchingItem[key]) | ||||
|             return false | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       return true | ||||
|     }, | ||||
|     checkForChanges() { | ||||
|       var metadata = {} | ||||
|       for (const key in this.details) { | ||||
|         var newValue = this.details[key] | ||||
|         var oldValue = this.mediaMetadata[key] | ||||
|         // Key cleared out or key first populated | ||||
|         if ((!newValue && oldValue) || (newValue && !oldValue)) { | ||||
|           metadata[key] = newValue | ||||
|         } else if (key === 'narrators' || key === 'genres') { | ||||
|           // Check array of strings | ||||
|           if (!this.stringArrayEqual(newValue, oldValue)) { | ||||
|             metadata[key] = [...newValue] | ||||
|           } | ||||
|         } else if (key === 'authors' || key === 'series') { | ||||
|           if (!this.objectArrayEqual(newValue, oldValue)) { | ||||
|             metadata[key] = newValue.map((v) => ({ ...v })) | ||||
|           } | ||||
|         } else if (newValue && newValue != oldValue) { | ||||
|           // Intentional != | ||||
|           metadata[key] = newValue | ||||
|         } | ||||
|       } | ||||
|       var updatePayload = {} | ||||
|       if (!!Object.keys(metadata).length) updatePayload.metadata = metadata | ||||
| 
 | ||||
|       if (!this.stringArrayEqual(this.newTags, this.media.tags || [])) { | ||||
|         updatePayload.tags = [...this.newTags] | ||||
|       } | ||||
|       return { | ||||
|         updatePayload, | ||||
|         hasChanges: !!Object.keys(updatePayload).length | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.details.title = this.mediaMetadata.title | ||||
|       this.details.subtitle = this.mediaMetadata.subtitle | ||||
|       this.details.description = this.mediaMetadata.description | ||||
|       this.details.authors = (this.mediaMetadata.authors || []).map((se) => ({ ...se })) | ||||
|       this.details.narrators = [...(this.mediaMetadata.narrators || [])] | ||||
|       this.details.genres = [...(this.mediaMetadata.genres || [])] | ||||
|       this.details.series = (this.mediaMetadata.series || []).map((se) => ({ ...se })) | ||||
|       this.details.publishYear = this.mediaMetadata.publishYear | ||||
|       this.details.publisher = this.mediaMetadata.publisher || null | ||||
|       this.details.language = this.mediaMetadata.language || null | ||||
|       this.details.isbn = this.mediaMetadata.isbn || null | ||||
|       this.details.asin = this.mediaMetadata.asin || null | ||||
|       this.details.explicit = !!this.mediaMetadata.explicit | ||||
|       this.newTags = [...(this.media.tags || [])] | ||||
|     }, | ||||
|     submitForm() { | ||||
|       this.$emit('submit') | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -32,8 +32,8 @@ export default { | ||||
|       if (this.$store.state.showEditModal) { | ||||
|         this.$store.commit('setShowEditModal', false) | ||||
|       } | ||||
|       if (this.$store.state.selectedAudiobooks) { | ||||
|         this.$store.commit('setSelectedAudiobooks', []) | ||||
|       if (this.$store.state.selectedLibraryItems) { | ||||
|         this.$store.commit('setSelectedLibraryItems', []) | ||||
|       } | ||||
|       if (this.$store.state.audiobooks.keywordFilter) { | ||||
|         this.$store.commit('audiobooks/setKeywordFilter', '') | ||||
| @ -486,9 +486,9 @@ export default { | ||||
|       } | ||||
| 
 | ||||
|       // Batch selecting | ||||
|       if (this.$store.getters['getNumAudiobooksSelected'] && name === 'Escape') { | ||||
|       if (this.$store.getters['getNumLibraryItemsSelected'] && name === 'Escape') { | ||||
|         // ESCAPE key cancels batch selection | ||||
|         this.$store.commit('setSelectedAudiobooks', []) | ||||
|         this.$store.commit('setSelectedLibraryItems', []) | ||||
|         this.$eventBus.$emit('bookshelf-clear-selection') | ||||
|         e.preventDefault() | ||||
|         return | ||||
|  | ||||
| @ -30,7 +30,7 @@ export default { | ||||
|         shelfEl.appendChild(bookComponent.$el) | ||||
|         if (this.isSelectionMode) { | ||||
|           bookComponent.setSelectionMode(true) | ||||
|           if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) { | ||||
|           if (this.selectedLibraryItems.includes(bookComponent.libraryItemId) || this.isSelectAll) { | ||||
|             bookComponent.selected = true | ||||
|           } else { | ||||
|             bookComponent.selected = false | ||||
| @ -85,7 +85,7 @@ export default { | ||||
|       } | ||||
|       if (this.isSelectionMode) { | ||||
|         instance.setSelectionMode(true) | ||||
|         if (instance.audiobookId && this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) { | ||||
|         if (instance.libraryItemId && this.selectedLibraryItems.includes(instance.libraryItemId) || this.isSelectAll) { | ||||
|           instance.selected = true | ||||
|         } | ||||
|       } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''"> | ||||
|   <div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamLibraryItem ? 'streaming' : ''"> | ||||
|     <div class="border border-white border-opacity-10 max-w-7xl mx-auto mb-10 mt-5"> | ||||
|       <div class="flex items-center px-4 py-4 cursor-pointer" @click="openMapOptions = !openMapOptions" @mousedown.prevent @mouseup.prevent> | ||||
|         <span class="material-icons">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span> | ||||
| @ -14,8 +14,9 @@ | ||||
|               <ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" label="Subtitle" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.author" /> | ||||
|               <ui-text-input-with-label ref="authorInput" v-model="batchDetails.author" :disabled="!selectedBatchUsage.author" label="Author" class="mb-4 ml-4" /> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.authors" /> | ||||
|               <!-- Authors filter only contains authors in this library, use query input to query all authors --> | ||||
|               <ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" label="Authors" endpoint="authors/search" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.publishYear" /> | ||||
| @ -23,7 +24,7 @@ | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.series" /> | ||||
|               <ui-input-dropdown ref="seriesDropdown" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" class="mb-4 ml-4" /> | ||||
|               <ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" label="Series" :items="seriesItems" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.genres" /> | ||||
| @ -34,8 +35,8 @@ | ||||
|               <ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" label="Tags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.narrator" /> | ||||
|               <ui-text-input-with-label ref="narratorInput" v-model="batchDetails.narrator" :disabled="!selectedBatchUsage.narrator" label="Narrator" class="mb-4 ml-4" /> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.narrators" /> | ||||
|               <ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" label="Narrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.publisher" /> | ||||
| @ -45,6 +46,20 @@ | ||||
|               <ui-checkbox v-model="selectedBatchUsage.language" /> | ||||
|               <ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" label="Language" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.explicit" /> | ||||
|               <div class="ml-4"> | ||||
|                 <ui-checkbox | ||||
|                   v-model="batchDetails.explicit" | ||||
|                   label="Explicit" | ||||
|                   :disabled="!selectedBatchUsage.explicit" | ||||
|                   :checkbox-bg="!selectedBatchUsage.explicit ? 'bg' : 'primary'" | ||||
|                   :check-color="!selectedBatchUsage.explicit ? 'gray-600' : 'green-500'" | ||||
|                   border-color="gray-600" | ||||
|                   :label-class="!selectedBatchUsage.explicit ? 'pl-2 text-base text-gray-400 font-semibold' : 'pl-2 text-base font-semibold'" | ||||
|                 /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="w-full flex items-center justify-end p-4"> | ||||
|               <ui-btn color="success" :disabled="!hasSelectedBatchUsage" :padding-x="8" small class="text-base" :loading="isProcessing" @click="mapBatchDetails">Apply</ui-btn> | ||||
| @ -55,71 +70,9 @@ | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="flex justify-center flex-wrap"> | ||||
|       <template v-for="audiobook in audiobookCopies"> | ||||
|         <div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex"> | ||||
|           <div class="w-32"> | ||||
|             <covers-book-cover :audiobook="audiobook.originalAudiobook" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
|           </div> | ||||
|           <div class="flex-grow pl-4"> | ||||
|             <ui-text-input-with-label v-model="audiobook.book.title" label="Title" /> | ||||
| 
 | ||||
|             <ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" /> | ||||
| 
 | ||||
|             <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-3/4 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.author" label="Author" /> | ||||
|               </div> | ||||
|               <div class="flex-grow px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-3/4 px-1"> | ||||
|                 <ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" /> | ||||
|               </div> | ||||
|               <div class="flex-grow px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <ui-textarea-with-label v-model="audiobook.book.description" :rows="3" label="Description" class="mt-2" /> | ||||
| 
 | ||||
|             <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-1/2 px-1"> | ||||
|                 <ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" /> | ||||
|               </div> | ||||
|               <div class="flex-grow px-1"> | ||||
|                 <ui-multi-select v-model="audiobook.tags" label="Tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <!-- <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-1/2 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" /> | ||||
|               </div> | ||||
|             </div> --> | ||||
|             <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-1/3 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" /> | ||||
|               </div> | ||||
|               <div class="w-1/3 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.publisher" label="Publisher" /> | ||||
|               </div> | ||||
|               <div class="flex-grow px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.language" label="Language" /> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="flex mt-2 -mx-1"> | ||||
|               <div class="w-1/3 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.isbn" label="ISBN" /> | ||||
|               </div> | ||||
|               <div class="w-1/3 px-1"> | ||||
|                 <ui-text-input-with-label v-model="audiobook.book.asin" label="ASIN" /> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|       <template v-for="libraryItem in libraryItemCopies"> | ||||
|         <div :key="libraryItem.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px"> | ||||
|           <widgets-item-details-edit :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" /> | ||||
|         </div> | ||||
|       </template> | ||||
|     </div> | ||||
| @ -127,7 +80,7 @@ | ||||
|       <ui-loading-indicator /> | ||||
|     </div> | ||||
| 
 | ||||
|     <div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }"> | ||||
|     <div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamLibraryItem ? '165px' : '0px' }"> | ||||
|       <div class="flex-grow" /> | ||||
|       <ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">Save</ui-btn> | ||||
|     </div> | ||||
| @ -137,47 +90,50 @@ | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ store, redirect, app }) { | ||||
|     if (!store.state.selectedAudiobooks.length) { | ||||
|     if (!store.state.selectedLibraryItems.length) { | ||||
|       return redirect('/') | ||||
|     } | ||||
|     var audiobooks = await app.$axios.$post(`/api/books/batch/get`, { books: store.state.selectedAudiobooks }).catch((error) => { | ||||
|       var errorMsg = error.response.data || 'Failed to get audiobooks' | ||||
|     var libraryItems = await app.$axios.$post(`/api/items/batch/get`, { libraryItemIds: store.state.selectedLibraryItems }).catch((error) => { | ||||
|       var errorMsg = error.response.data || 'Failed to get items' | ||||
|       console.error(errorMsg, error) | ||||
|       return [] | ||||
|     }) | ||||
|     return { | ||||
|       audiobooks | ||||
|       libraryItems | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       isProcessing: false, | ||||
|       audiobookCopies: [], | ||||
|       libraryItemCopies: [], | ||||
|       isScrollable: false, | ||||
|       newSeriesItems: [], | ||||
|       newSeriesNames: [], | ||||
|       newTagItems: [], | ||||
|       newGenreItems: [], | ||||
|       newNarratorItems: [], | ||||
|       batchDetails: { | ||||
|         subtitle: null, | ||||
|         author: null, | ||||
|         authors: null, | ||||
|         publishYear: null, | ||||
|         series: null, | ||||
|         series: [], | ||||
|         genres: [], | ||||
|         tags: [], | ||||
|         narrator: null, | ||||
|         narrators: [], | ||||
|         publisher: null, | ||||
|         language: null | ||||
|         language: null, | ||||
|         explicit: false | ||||
|       }, | ||||
|       selectedBatchUsage: { | ||||
|         subtitle: false, | ||||
|         author: false, | ||||
|         authors: false, | ||||
|         publishYear: false, | ||||
|         series: false, | ||||
|         genres: false, | ||||
|         tags: false, | ||||
|         narrator: false, | ||||
|         narrators: false, | ||||
|         publisher: false, | ||||
|         language: false | ||||
|         language: false, | ||||
|         explicit: false | ||||
|       }, | ||||
|       openMapOptions: false | ||||
|     } | ||||
| @ -189,8 +145,8 @@ export default { | ||||
|     bookCoverAspectRatio() { | ||||
|       return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 | ||||
|     }, | ||||
|     streamAudiobook() { | ||||
|       return this.$store.state.streamAudiobook | ||||
|     streamLibraryItem() { | ||||
|       return this.$store.state.streamLibraryItem | ||||
|     }, | ||||
|     genreItems() { | ||||
|       return this.genres.concat(this.newGenreItems) | ||||
| @ -199,7 +155,10 @@ export default { | ||||
|       return this.tags.concat(this.newTagItems) | ||||
|     }, | ||||
|     seriesItems() { | ||||
|       return [...this.series, ...this.newSeriesItems] | ||||
|       return [...this.existingSeriesNames, ...this.newSeriesNames] | ||||
|     }, | ||||
|     narratorItems() { | ||||
|       return [...this.narrators, ...this.newNarratorItems] | ||||
|     }, | ||||
|     genres() { | ||||
|       return this.filterData.genres || [] | ||||
| @ -210,6 +169,15 @@ export default { | ||||
|     series() { | ||||
|       return this.filterData.series || [] | ||||
|     }, | ||||
|     narrators() { | ||||
|       return this.filterData.narrators || [] | ||||
|     }, | ||||
|     authors() { | ||||
|       return this.filterData.authors || [] | ||||
|     }, | ||||
|     existingSeriesNames() { | ||||
|       return this.series.map((se) => se.name) | ||||
|     }, | ||||
|     filterData() { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     }, | ||||
| @ -222,8 +190,14 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     blurBatchForm() { | ||||
|       if (this.$refs.seriesDropdown && this.$refs.seriesDropdown.isFocused) { | ||||
|         this.$refs.seriesDropdown.blur() | ||||
|       if (this.$refs.seriesSelect && this.$refs.seriesSelect.isFocused) { | ||||
|         this.$refs.seriesSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.authorsSelect && this.$refs.authorsSelect.isFocused) { | ||||
|         this.$refs.authorsSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.narratorsSelect && this.$refs.narratorsSelect.isFocused) { | ||||
|         this.$refs.narratorsSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { | ||||
|         this.$refs.genresSelect.forceBlur() | ||||
| @ -241,69 +215,92 @@ export default { | ||||
|     mapBatchDetails() { | ||||
|       this.blurBatchForm() | ||||
| 
 | ||||
|       this.audiobookCopies = this.audiobookCopies.map((ab) => { | ||||
|         for (const key in this.selectedBatchUsage) { | ||||
|           if (this.selectedBatchUsage[key]) { | ||||
|             if (key === 'tags') { | ||||
|               ab.tags = this.batchDetails.tags | ||||
|             } else { | ||||
|               ab.book[key] = this.batchDetails[key] | ||||
|             } | ||||
|       var batchMapPayload = {} | ||||
|       for (const key in this.selectedBatchUsage) { | ||||
|         if (this.selectedBatchUsage[key]) { | ||||
|           if (key === 'series') { | ||||
|             // Map string of series to series objects | ||||
|             batchMapPayload[key] = this.batchDetails[key].map((seItem) => { | ||||
|               var existingSeries = this.series.find((se) => se.name.toLowerCase() === seItem.toLowerCase().trim()) | ||||
|               if (existingSeries) { | ||||
|                 return existingSeries | ||||
|               } else { | ||||
|                 return { | ||||
|                   id: `new-${Math.floor(Math.random() * 10000)}`, | ||||
|                   name: seItem | ||||
|                 } | ||||
|               } | ||||
|             }) | ||||
|           } else { | ||||
|             batchMapPayload[key] = this.batchDetails[key] | ||||
|           } | ||||
|         } | ||||
|         return ab | ||||
|       } | ||||
| 
 | ||||
|       this.libraryItemCopies.forEach((li) => { | ||||
|         var ref = this.getEditFormRef(li.id) | ||||
|         ref.mapBatchDetails(batchMapPayload) | ||||
|       }) | ||||
|       this.$toast.success('Details mapped') | ||||
|     }, | ||||
|     newSeriesItem(item) {}, | ||||
|     removedSeriesItem(item) {}, | ||||
|     newNarratorItem(item) {}, | ||||
|     removedNarratorItem(item) {}, | ||||
|     newTagItem(item) { | ||||
|       if (item && !this.newTagItems.includes(item)) { | ||||
|         this.newTagItems.push(item) | ||||
|       } | ||||
|       // if (item && !this.newTagItems.includes(item)) { | ||||
|       //   this.newTagItems.push(item) | ||||
|       // } | ||||
|     }, | ||||
|     removedTagItem(item) { | ||||
|       // If newly added, remove if not used on any other audiobooks | ||||
|       if (item && this.newTagItems.includes(item)) { | ||||
|         var usedByOtherAb = this.audiobookCopies.find((ab) => { | ||||
|           return ab.tags && ab.tags.includes(item) | ||||
|         }) | ||||
|         if (!usedByOtherAb) { | ||||
|           this.newTagItems = this.newTagItems.filter((t) => t !== item) | ||||
|         } | ||||
|       } | ||||
|       // If newly added, remove if not used on any other items | ||||
|       // if (item && this.newTagItems.includes(item)) { | ||||
|       //   var usedByOtherAb = this.libraryItemCopies.find((ab) => { | ||||
|       //     return ab.tags && ab.tags.includes(item) | ||||
|       //   }) | ||||
|       //   if (!usedByOtherAb) { | ||||
|       //     this.newTagItems = this.newTagItems.filter((t) => t !== item) | ||||
|       //   } | ||||
|       // } | ||||
|     }, | ||||
|     newGenreItem(item) { | ||||
|       if (item && !this.newGenreItems.includes(item)) { | ||||
|         this.newGenreItems.push(item) | ||||
|       } | ||||
|       // if (item && !this.newGenreItems.includes(item)) { | ||||
|       //   this.newGenreItems.push(item) | ||||
|       // } | ||||
|     }, | ||||
|     removedGenreItem(item) { | ||||
|       // If newly added, remove if not used on any other audiobooks | ||||
|       if (item && this.newGenreItems.includes(item)) { | ||||
|         var usedByOtherAb = this.audiobookCopies.find((ab) => { | ||||
|           return ab.book.genres && ab.book.genres.includes(item) | ||||
|         }) | ||||
|         if (!usedByOtherAb) { | ||||
|           this.newGenreItems = this.newGenreItems.filter((t) => t !== item) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     newSeriesItem(item) { | ||||
|       if (item && !this.newSeriesItems.includes(item)) { | ||||
|         this.newSeriesItems.push(item) | ||||
|       } | ||||
|     }, | ||||
|     seriesChanged() { | ||||
|       this.newSeriesItems = this.newSeriesItems.filter((item) => { | ||||
|         return this.audiobookCopies.find((ab) => ab.book.series === item) | ||||
|       }) | ||||
|       // If newly added, remove if not used on any other items | ||||
|       // if (item && this.newGenreItems.includes(item)) { | ||||
|       //   var usedByOtherAb = this.libraryItemCopies.find((ab) => { | ||||
|       //     return ab.book.genres && ab.book.genres.includes(item) | ||||
|       //   }) | ||||
|       //   if (!usedByOtherAb) { | ||||
|       //     this.newGenreItems = this.newGenreItems.filter((t) => t !== item) | ||||
|       //   } | ||||
|       // } | ||||
|     }, | ||||
|     init() { | ||||
|       this.audiobookCopies = this.audiobooks.map((ab) => { | ||||
|         var copy = { ...ab } | ||||
|         copy.tags = [...ab.tags] | ||||
|         copy.book = { ...ab.book } | ||||
|         copy.book.genres = [...ab.book.genres] | ||||
|         copy.originalAudiobook = ab | ||||
|       // TODO: Better deep cloning of library items | ||||
|       this.libraryItemCopies = this.libraryItems.map((li) => { | ||||
|         var copy = { | ||||
|           ...li | ||||
|         } | ||||
|         copy.media = { ...li.media } | ||||
|         if (copy.media.tags) copy.media.tags = [...copy.media.tags] | ||||
|         copy.media.metadata = { ...copy.media.metadata } | ||||
|         if (copy.media.metadata.authors) { | ||||
|           copy.media.metadata.authors = copy.media.metadata.authors.map((au) => ({ ...au })) | ||||
|         } | ||||
|         if (copy.media.metadata.series) { | ||||
|           copy.media.metadata.series = copy.media.metadata.series.map((se) => ({ ...se })) | ||||
|         } | ||||
|         if (copy.media.metadata.narrators) { | ||||
|           copy.media.metadata.narrators = [...copy.media.metadata.narrators] | ||||
|         } | ||||
|         if (copy.media.metadata.genres) { | ||||
|           copy.media.metadata.genres = [...copy.media.metadata.genres] | ||||
|         } | ||||
|         copy.originalLibraryItem = li | ||||
|         return copy | ||||
|       }) | ||||
|       this.$nextTick(() => { | ||||
| @ -312,46 +309,23 @@ export default { | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     compareStringArrays(arr1, arr2) { | ||||
|       if (!arr1 || !arr2) return false | ||||
|       return arr1.join(',') !== arr2.join(',') | ||||
|     }, | ||||
|     compareAudiobooks(newAb, origAb) { | ||||
|       const bookKeysToCheck = ['title', 'subtitle', 'narrator', 'author', 'publishYear', 'series', 'volumeNumber', 'description', 'language', 'publisher', 'isbn', 'asin'] | ||||
|       var newBook = newAb.book | ||||
|       var origBook = origAb.book | ||||
|       var diffObj = {} | ||||
|       for (const key in newBook) { | ||||
|         if (bookKeysToCheck.includes(key)) { | ||||
|           if (newBook[key] !== origBook[key]) { | ||||
|             if (!diffObj.book) diffObj.book = {} | ||||
|             diffObj.book[key] = newBook[key] | ||||
|           } | ||||
|         } | ||||
|         if (key === 'genres') { | ||||
|           if (this.compareStringArrays(newBook[key], origBook[key])) { | ||||
|             if (!diffObj.book) diffObj.book = {} | ||||
|             diffObj.book[key] = newBook[key] | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       if (newAb.tags && origAb.tags && newAb.tags.join(',') !== origAb.tags.join(',')) { | ||||
|         diffObj.tags = newAb.tags | ||||
|       } | ||||
|       return diffObj | ||||
|     getEditFormRef(itemId) { | ||||
|       var refs = this.$refs[`itemForm-${itemId}`] | ||||
|       if (refs && refs.length) return refs[0] | ||||
|       return null | ||||
|     }, | ||||
|     saveClick() { | ||||
|       var updates = [] | ||||
|       for (let i = 0; i < this.audiobookCopies.length; i++) { | ||||
|         var ab = { ...this.audiobookCopies[i] } | ||||
|         var origAb = ab.originalAudiobook | ||||
|         delete ab.originalAudiobook | ||||
| 
 | ||||
|         var res = this.compareAudiobooks(ab, origAb) | ||||
|         if (res && Object.keys(res).length) { | ||||
|       for (let i = 0; i < this.libraryItemCopies.length; i++) { | ||||
|         var editForm = this.getEditFormRef(this.libraryItemCopies[i].id) | ||||
|         if (!editForm) { | ||||
|           throw new Error('Invalid edit form ref not found') | ||||
|         } | ||||
|         var details = editForm.getDetails() | ||||
|         if (details.hasChanges) { | ||||
|           updates.push({ | ||||
|             id: ab.id, | ||||
|             updates: res | ||||
|             id: this.libraryItemCopies[i].id, | ||||
|             mediaPayload: details.updatePayload | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
| @ -362,11 +336,11 @@ export default { | ||||
|       console.log('Pushing updates', updates) | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$post('/api/books/batch/update', updates) | ||||
|         .$post('/api/items/batch/update', updates) | ||||
|         .then((data) => { | ||||
|           this.isProcessing = false | ||||
|           if (data.updates) { | ||||
|             this.$toast.success(`Successfully updated ${data.updates} audiobooks`) | ||||
|             this.$toast.success(`Successfully updated ${data.updates} items`) | ||||
|             this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`) | ||||
|           } else { | ||||
|             this.$toast.warning('No updates were necessary') | ||||
| @ -377,11 +351,6 @@ export default { | ||||
|           this.$toast.error('Failed to batch update') | ||||
|           this.isProcessing = false | ||||
|         }) | ||||
|     }, | ||||
|     applyBatchUpdates() { | ||||
|       this.audiobookCopies = this.audiobookCopies.map((ab) => { | ||||
|         if (this.batchDetails.series) ab.book.series = this.batchDetails.series | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|  | ||||
| @ -12,7 +12,7 @@ export const state = () => ({ | ||||
|   selectedLibraryItem: null, | ||||
|   selectedAudiobookFile: null, | ||||
|   developerMode: false, | ||||
|   selectedAudiobooks: [], | ||||
|   selectedLibraryItems: [], | ||||
|   processingBatch: false, | ||||
|   previousPath: '/', | ||||
|   routeHistory: [], | ||||
| @ -25,8 +25,8 @@ export const state = () => ({ | ||||
| }) | ||||
| 
 | ||||
| export const getters = { | ||||
|   getIsAudiobookSelected: state => audiobookId => { | ||||
|     return !!state.selectedAudiobooks.includes(audiobookId) | ||||
|   getIsLibraryItemSelected: state => libraryItemId => { | ||||
|     return !!state.selectedLibraryItems.includes(libraryItemId) | ||||
|   }, | ||||
|   getServerSetting: state => key => { | ||||
|     if (!state.serverSettings) return null | ||||
| @ -36,7 +36,7 @@ export const getters = { | ||||
|     if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6 | ||||
|     return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1 | ||||
|   }, | ||||
|   getNumAudiobooksSelected: state => state.selectedAudiobooks.length, | ||||
|   getNumLibraryItemsSelected: state => state.selectedLibraryItems.length, | ||||
|   getLibraryItemIdStreaming: state => { | ||||
|     return state.streamLibraryItem ? state.streamLibraryItem.id : null | ||||
|   } | ||||
| @ -146,24 +146,24 @@ export const mutations = { | ||||
|   setSelectedLibraryItem(state, val) { | ||||
|     Vue.set(state, 'selectedLibraryItem', val) | ||||
|   }, | ||||
|   setSelectedAudiobooks(state, audiobooks) { | ||||
|     Vue.set(state, 'selectedAudiobooks', audiobooks) | ||||
|   setSelectedLibraryItems(state, items) { | ||||
|     Vue.set(state, 'selectedLibraryItems', items) | ||||
|   }, | ||||
|   toggleAudiobookSelected(state, audiobookId) { | ||||
|     if (state.selectedAudiobooks.includes(audiobookId)) { | ||||
|       state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) | ||||
|   toggleLibraryItemSelected(state, itemId) { | ||||
|     if (state.selectedLibraryItems.includes(itemId)) { | ||||
|       state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== itemId) | ||||
|     } else { | ||||
|       var newSel = state.selectedAudiobooks.concat([audiobookId]) | ||||
|       Vue.set(state, 'selectedAudiobooks', newSel) | ||||
|       var newSel = state.selectedLibraryItems.concat([itemId]) | ||||
|       Vue.set(state, 'selectedLibraryItems', newSel) | ||||
|     } | ||||
|   }, | ||||
|   setAudiobookSelected(state, { audiobookId, selected }) { | ||||
|     var isThere = state.selectedAudiobooks.includes(audiobookId) | ||||
|   setLibraryItemSelected(state, { libraryItemId, selected }) { | ||||
|     var isThere = state.selectedLibraryItems.includes(libraryItemId) | ||||
|     if (isThere && !selected) { | ||||
|       state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) | ||||
|       state.selectedLibraryItems = state.selectedLibraryItems.filter(a => a !== libraryItemId) | ||||
|     } else if (selected && !isThere) { | ||||
|       var newSel = state.selectedAudiobooks.concat([audiobookId]) | ||||
|       Vue.set(state, 'selectedAudiobooks', newSel) | ||||
|       var newSel = state.selectedLibraryItems.concat([libraryItemId]) | ||||
|       Vue.set(state, 'selectedLibraryItems', newSel) | ||||
|     } | ||||
|   }, | ||||
|   setProcessingBatch(state, val) { | ||||
|  | ||||
| @ -21,6 +21,9 @@ const AuthorController = require('./controllers/AuthorController') | ||||
| const BookFinder = require('./finders/BookFinder') | ||||
| const AuthorFinder = require('./finders/AuthorFinder') | ||||
| const PodcastFinder = require('./finders/PodcastFinder') | ||||
| 
 | ||||
| const Author = require('./objects/entities/Author') | ||||
| const Series = require('./objects/entities/Series') | ||||
| const FileSystemController = require('./controllers/FileSystemController') | ||||
| 
 | ||||
| class ApiController { | ||||
| @ -65,11 +68,8 @@ class ApiController { | ||||
|     this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) | ||||
|     this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) | ||||
|     this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this)) | ||||
|     this.router.post('/libraries/order', LibraryController.reorder.bind(this)) | ||||
| 
 | ||||
|     // Legacy
 | ||||
|     this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this)) | ||||
|     this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this)) | ||||
|     this.router.post('/libraries/order', LibraryController.reorder.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Item Routes
 | ||||
| @ -84,6 +84,10 @@ class ApiController { | ||||
|     this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this)) | ||||
|     this.router.get('/items/:id/stream', LibraryItemController.middleware.bind(this), LibraryItemController.openStream.bind(this)) | ||||
| 
 | ||||
|     this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this)) | ||||
|     this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this)) | ||||
|     this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Book Routes
 | ||||
|     //
 | ||||
| @ -93,9 +97,6 @@ class ApiController { | ||||
|     this.router.delete('/books/:id', BookController.delete.bind(this)) | ||||
| 
 | ||||
|     this.router.delete('/books/all', BookController.deleteAll.bind(this)) | ||||
|     this.router.post('/books/batch/delete', BookController.batchDelete.bind(this)) | ||||
|     this.router.post('/books/batch/update', BookController.batchUpdate.bind(this)) | ||||
|     this.router.post('/books/batch/get', BookController.batchGet.bind(this)) | ||||
|     this.router.patch('/books/:id/tracks', BookController.updateTracks.bind(this)) | ||||
|     this.router.get('/books/:id/stream', BookController.openStream.bind(this)) | ||||
|     this.router.post('/books/:id/cover', BookController.uploadCover.bind(this)) | ||||
| @ -162,17 +163,16 @@ class ApiController { | ||||
|     //
 | ||||
|     // Author Routes
 | ||||
|     //
 | ||||
|     this.router.get('/authors/search', AuthorController.search.bind(this)) | ||||
|     this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this)) | ||||
|     this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) | ||||
|     this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) | ||||
|     this.router.get('/authors/search', AuthorController.search.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Series Routes
 | ||||
|     //
 | ||||
|     this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this)) | ||||
|     this.router.get('/series/search', SeriesController.search.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Misc Routes
 | ||||
| @ -488,6 +488,60 @@ class ApiController { | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   async createAuthorsAndSeriesForItemUpdate(mediaPayload) { | ||||
|     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 author = this.db.authors.find(au => au.checkNameEquals(mediaMetadata.authors[i].name)) | ||||
|             if (!author) { | ||||
|               author = new Author() | ||||
|               author.setData(mediaMetadata.authors[i]) | ||||
|               Logger.debug(`[ApiController] Created new author "${author.name}"`) | ||||
|               newAuthors.push(author) | ||||
|             } | ||||
| 
 | ||||
|             // Update ID in original payload
 | ||||
|             mediaMetadata.authors[i].id = author.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 seriesItem = this.db.series.find(se => se.checkNameEquals(mediaMetadata.series[i].name)) | ||||
|             if (!seriesItem) { | ||||
|               seriesItem = new Series() | ||||
|               seriesItem.setData(mediaMetadata.series[i]) | ||||
|               Logger.debug(`[ApiController] Created new series "${seriesItem.name}"`) | ||||
|               newSeries.push(seriesItem) | ||||
|             } | ||||
| 
 | ||||
|             // Update ID in original payload
 | ||||
|             mediaMetadata.series[i].id = seriesItem.id | ||||
|           } | ||||
|         } | ||||
|         if (newSeries.length) { | ||||
|           await this.db.insertEntities('series', newSeries) | ||||
|           this.emitter('authors_added', newSeries) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   getPodcastFeed(req, res) { | ||||
|     var url = req.body.rssFeed | ||||
|     if (!url) { | ||||
|  | ||||
| @ -226,14 +226,6 @@ class LibraryController { | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // LEGACY
 | ||||
|   // api/libraries/:id/books/filters
 | ||||
|   async getLibraryFilters(req, res) { | ||||
|     var library = req.library | ||||
|     var books = this.db.audiobooks.filter(ab => ab.libraryId === library.id) | ||||
|     res.json(libraryHelpers.getDistinctFilterData(books)) | ||||
|   } | ||||
| 
 | ||||
|   async getLibraryFilterData(req, res) { | ||||
|     res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems)) | ||||
|   } | ||||
|  | ||||
| @ -1,6 +1,4 @@ | ||||
| const Logger = require('../Logger') | ||||
| const Author = require('../objects/entities/Author') | ||||
| const Series = require('../objects/entities/Series') | ||||
| const { reqSupportsWebp } = require('../utils/index') | ||||
| 
 | ||||
| class LibraryItemController { | ||||
| @ -43,49 +41,7 @@ class LibraryItemController { | ||||
|       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) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     await this.createAuthorsAndSeriesForItemUpdate(mediaPayload) | ||||
| 
 | ||||
|     var hasUpdates = libraryItem.media.update(mediaPayload) | ||||
|     if (hasUpdates) { | ||||
| @ -182,11 +138,76 @@ class LibraryItemController { | ||||
|     this.streamManager.openStreamApiRequest(res, req.user, req.libraryItem) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/items/batch/delete
 | ||||
|   async batchDelete(req, res) { | ||||
|     if (!req.user.canDelete) { | ||||
|       Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user) | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     var { libraryItemIds } = req.body | ||||
|     if (!libraryItemIds || !libraryItemIds.length) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     var itemsToDelete = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id)) | ||||
|     if (!itemsToDelete.length) { | ||||
|       return res.sendStatus(404) | ||||
|     } | ||||
|     for (let i = 0; i < itemsToDelete.length; i++) { | ||||
|       Logger.info(`[LibraryItemController] Deleting Library Item "${itemsToDelete[i].media.metadata.title}"`) | ||||
|       await this.handleDeleteLibraryItem(itemsToDelete[i]) | ||||
|     } | ||||
|     res.sendStatus(200) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/items/batch/update
 | ||||
|   async batchUpdate(req, res) { | ||||
|     var updatePayloads = req.body | ||||
|     if (!updatePayloads || !updatePayloads.length) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
| 
 | ||||
|     var itemsUpdated = 0 | ||||
| 
 | ||||
|     for (let i = 0; i < updatePayloads.length; i++) { | ||||
|       var mediaPayload = updatePayloads[i].mediaPayload | ||||
|       var libraryItem = this.db.libraryItems.find(_li => _li.id === updatePayloads[i].id) | ||||
|       if (!libraryItem) return null | ||||
| 
 | ||||
|       await this.createAuthorsAndSeriesForItemUpdate(mediaPayload) | ||||
| 
 | ||||
|       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()) | ||||
|         itemsUpdated++ | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     res.json({ | ||||
|       success: true, | ||||
|       updates: itemsUpdated | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   // POST: api/items/batch/get
 | ||||
|   async batchGet(req, res) { | ||||
|     var libraryItemIds = req.body.libraryItemIds || [] | ||||
|     if (!libraryItemIds.length) { | ||||
|       return res.status(403).send('Invalid payload') | ||||
|     } | ||||
|     var libraryItems = this.db.libraryItems.filter(li => libraryItemIds.includes(li.id)).map((li) => li.toJSONExpanded()) | ||||
|     res.json(libraryItems) | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   middleware(req, res, next) { | ||||
|     var item = this.db.libraryItems.find(li => li.id === req.params.id) | ||||
|     if (!item || !item.media) return res.sendStatus(404) | ||||
| 
 | ||||
|     // Check user can access this audiobooks library
 | ||||
|     // Check user can access this library
 | ||||
|     if (!req.user.checkCanAccessLibrary(item.libraryId)) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
|  | ||||
| @ -133,50 +133,6 @@ module.exports = { | ||||
|     return data | ||||
|   }, | ||||
| 
 | ||||
| 
 | ||||
|   // TODO: Remove legacy
 | ||||
|   getDistinctFilterData(audiobooks) { | ||||
|     var data = { | ||||
|       authors: [], | ||||
|       genres: [], | ||||
|       tags: [], | ||||
|       series: [], | ||||
|       narrators: [], | ||||
|       languages: [] | ||||
|     } | ||||
|     audiobooks.forEach((ab) => { | ||||
|       if (ab.book._authorsList.length) { | ||||
|         ab.book._authorsList.forEach((author) => { | ||||
|           if (author && !data.authors.includes(author)) data.authors.push(author) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.book._genres.length) { | ||||
|         ab.book._genres.forEach((genre) => { | ||||
|           if (genre && !data.genres.includes(genre)) data.genres.push(genre) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.tags.length) { | ||||
|         ab.tags.forEach((tag) => { | ||||
|           if (tag && !data.tags.includes(tag)) data.tags.push(tag) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.book._series && !data.series.includes(ab.book._series)) data.series.push(ab.book._series) | ||||
|       if (ab.book._narratorsList.length) { | ||||
|         ab.book._narratorsList.forEach((narrator) => { | ||||
|           if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator) | ||||
|         }) | ||||
|       } | ||||
|       if (ab.book._language && !data.languages.includes(ab.book._language)) data.languages.push(ab.book._language) | ||||
|     }) | ||||
|     data.authors = naturalSort(data.authors).asc() | ||||
|     data.genres = naturalSort(data.genres).asc() | ||||
|     data.tags = naturalSort(data.tags).asc() | ||||
|     data.series = naturalSort(data.series).asc() | ||||
|     data.narrators = naturalSort(data.narrators).asc() | ||||
|     data.languages = naturalSort(data.languages).asc() | ||||
|     return data | ||||
|   }, | ||||
| 
 | ||||
|   getSeriesFromBooks(books, minified = false) { | ||||
|     var _series = {} | ||||
|     books.forEach((libraryItem) => { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user