mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			409 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			409 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <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 text-2xl">{{ openMapOptions ? 'expand_less' : 'expand_more' }}</span>
 | |
| 
 | |
|         <p class="ml-4 text-gray-200 text-lg">{{ $strings.HeaderMapDetails }}</p>
 | |
| 
 | |
|         <div class="flex-grow" />
 | |
| 
 | |
|         <div class="w-64 flex">
 | |
|           <button class="w-32 h-8 rounded-l-md shadow-md border border-gray-600" :class="!isMapOverwrite ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'overwrite'">
 | |
|             <p class="text-sm">{{ $strings.LabelOverwrite }}</p>
 | |
|           </button>
 | |
|           <button class="w-32 h-8 rounded-r-md shadow-md border border-gray-600" :class="!isMapAppend ? 'bg-bg text-white/30' : 'bg-primary'" @click.stop.prevent="mapDetailsType = 'append'">
 | |
|             <p class="text-sm">{{ $strings.LabelAppend }}</p>
 | |
|           </button>
 | |
|         </div>
 | |
|       </div>
 | |
|       <div class="overflow-hidden">
 | |
|         <transition name="slide">
 | |
|           <div v-if="openMapOptions" class="flex flex-wrap">
 | |
|             <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.subtitle" />
 | |
|               <ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.authors" />
 | |
|               <!-- Authors filter only contains authors in this library, uses filter data -->
 | |
|               <ui-multi-select-query-input ref="authorsSelect" v-model="batchDetails.authors" :disabled="!selectedBatchUsage.authors" :label="$strings.LabelAuthors" filter-key="authors" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.publishedYear" />
 | |
|               <ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.series" />
 | |
|               <ui-multi-select ref="seriesSelect" v-model="batchDetails.series" :disabled="!selectedBatchUsage.series" :label="$strings.LabelSeries" :items="existingSeriesNames" @newItem="newSeriesItem" @removedItem="removedSeriesItem" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.genres" />
 | |
|               <ui-multi-select ref="genresSelect" v-model="batchDetails.genres" :disabled="!selectedBatchUsage.genres" :label="$strings.LabelGenres" :items="genreItems" @newItem="newGenreItem" @removedItem="removedGenreItem" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.tags" />
 | |
|               <ui-multi-select ref="tagsSelect" v-model="batchDetails.tags" :label="$strings.LabelTags" :disabled="!selectedBatchUsage.tags" :items="tagItems" @newItem="newTagItem" @removedItem="removedTagItem" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.narrators" />
 | |
|               <ui-multi-select ref="narratorsSelect" v-model="batchDetails.narrators" :disabled="!selectedBatchUsage.narrators" :label="$strings.LabelNarrators" :items="narratorItems" @newItem="newNarratorItem" @removedItem="removedNarratorItem" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.publisher" />
 | |
|               <ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.language" />
 | |
|               <ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-5 ml-4" />
 | |
|             </div>
 | |
|             <div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.explicit" />
 | |
|               <div class="ml-4">
 | |
|                 <ui-checkbox
 | |
|                   v-model="batchDetails.explicit"
 | |
|                   :label="$strings.LabelExplicit"
 | |
|                   :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 v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
 | |
|               <ui-checkbox v-model="selectedBatchUsage.abridged" />
 | |
|               <div class="ml-4">
 | |
|                 <ui-checkbox
 | |
|                   v-model="batchDetails.abridged"
 | |
|                   :label="$strings.LabelAbridged"
 | |
|                   :disabled="!selectedBatchUsage.abridged"
 | |
|                   :checkbox-bg="!selectedBatchUsage.abridged ? 'bg' : 'primary'"
 | |
|                   :check-color="!selectedBatchUsage.abridged ? 'gray-600' : 'green-500'"
 | |
|                   border-color="gray-600"
 | |
|                   :label-class="!selectedBatchUsage.abridged ? '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">{{ $strings.ButtonApply }}</ui-btn>
 | |
|             </div>
 | |
|           </div>
 | |
|         </transition>
 | |
|       </div>
 | |
|     </div>
 | |
| 
 | |
|     <div class="flex justify-center flex-wrap">
 | |
|       <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-book-details-edit v-if="libraryItem.mediaType === 'book'" :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
 | |
|           <widgets-podcast-details-edit v-else :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" />
 | |
|         </div>
 | |
|       </template>
 | |
|     </div>
 | |
|     <div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
 | |
|       <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: streamLibraryItem ? '165px' : '0px' }">
 | |
|       <div class="flex-grow" />
 | |
|       <ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click.prevent="saveClick">{{ $strings.ButtonSave }}</ui-btn>
 | |
|     </div>
 | |
|   </div>
 | |
| </template>
 | |
| 
 | |
| <script>
 | |
| export default {
 | |
|   async asyncData({ store, redirect, app }) {
 | |
|     if (!store.state.globals.selectedMediaItems.length) {
 | |
|       return redirect('/')
 | |
|     }
 | |
| 
 | |
|     const libraryItemIds = store.state.globals.selectedMediaItems.map((i) => i.id)
 | |
|     const libraryItems = await app.$axios
 | |
|       .$post(`/api/items/batch/get`, { libraryItemIds })
 | |
|       .then((res) => res.libraryItems)
 | |
|       .catch((error) => {
 | |
|         const errorMsg = error.response.data || 'Failed to get items'
 | |
|         console.error(errorMsg, error)
 | |
|         return []
 | |
|       })
 | |
|     return {
 | |
|       mediaType: libraryItems[0].mediaType,
 | |
|       libraryItems
 | |
|     }
 | |
|   },
 | |
|   data() {
 | |
|     return {
 | |
|       isProcessing: false,
 | |
|       libraryItemCopies: [],
 | |
|       isScrollable: false,
 | |
|       newTagItems: [],
 | |
|       newGenreItems: [],
 | |
|       newNarratorItems: [],
 | |
|       mapDetailsType: 'overwrite',
 | |
|       batchDetails: {
 | |
|         subtitle: null,
 | |
|         authors: null,
 | |
|         publishedYear: null,
 | |
|         series: [],
 | |
|         genres: [],
 | |
|         tags: [],
 | |
|         narrators: [],
 | |
|         publisher: null,
 | |
|         language: null,
 | |
|         explicit: false,
 | |
|         abridged: false
 | |
|       },
 | |
|       selectedBatchUsage: {
 | |
|         subtitle: false,
 | |
|         authors: false,
 | |
|         publishedYear: false,
 | |
|         series: false,
 | |
|         genres: false,
 | |
|         tags: false,
 | |
|         narrators: false,
 | |
|         publisher: false,
 | |
|         language: false,
 | |
|         explicit: false,
 | |
|         abridged: false
 | |
|       },
 | |
|       appendableKeys: ['authors', 'genres', 'tags', 'narrators', 'series'],
 | |
|       openMapOptions: false
 | |
|     }
 | |
|   },
 | |
|   computed: {
 | |
|     isMapOverwrite() {
 | |
|       return this.mapDetailsType === 'overwrite'
 | |
|     },
 | |
|     isMapAppend() {
 | |
|       return this.mapDetailsType === 'append'
 | |
|     },
 | |
|     isPodcastLibrary() {
 | |
|       return this.mediaType === 'podcast'
 | |
|     },
 | |
|     streamLibraryItem() {
 | |
|       return this.$store.state.streamLibraryItem
 | |
|     },
 | |
|     genreItems() {
 | |
|       return this.genres.concat(this.newGenreItems)
 | |
|     },
 | |
|     tagItems() {
 | |
|       return this.tags.concat(this.newTagItems)
 | |
|     },
 | |
|     narratorItems() {
 | |
|       return [...this.narrators, ...this.newNarratorItems]
 | |
|     },
 | |
|     genres() {
 | |
|       return this.filterData.genres || []
 | |
|     },
 | |
|     tags() {
 | |
|       return this.filterData.tags || []
 | |
|     },
 | |
|     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 || {}
 | |
|     },
 | |
|     currentLibraryId() {
 | |
|       return this.$store.state.libraries.currentLibraryId
 | |
|     },
 | |
|     hasSelectedBatchUsage() {
 | |
|       return Object.values(this.selectedBatchUsage).some((b) => !!b)
 | |
|     }
 | |
|   },
 | |
|   methods: {
 | |
|     blurBatchForm() {
 | |
|       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()
 | |
|       }
 | |
|       if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) {
 | |
|         this.$refs.tagsSelect.forceBlur()
 | |
|       }
 | |
| 
 | |
|       for (const key in this.batchDetails) {
 | |
|         if (this.$refs[`${key}Input`] && this.$refs[`${key}Input`].blur) {
 | |
|           this.$refs[`${key}Input`].blur()
 | |
|         }
 | |
|       }
 | |
|     },
 | |
|     mapBatchDetails() {
 | |
|       this.blurBatchForm()
 | |
| 
 | |
|       const batchMapPayload = {}
 | |
|       for (const key in this.selectedBatchUsage) {
 | |
|         if (!this.selectedBatchUsage[key]) continue
 | |
|         if (this.isMapAppend && !this.appendableKeys.includes(key)) continue
 | |
| 
 | |
|         if (key === 'series') {
 | |
|           // Map string of series to series objects
 | |
|           batchMapPayload[key] = this.batchDetails[key].map((seItem) => {
 | |
|             const 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]
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       this.libraryItemCopies.forEach((li) => {
 | |
|         const ref = this.getEditFormRef(li.id)
 | |
|         ref.mapBatchDetails(batchMapPayload, this.mapDetailsType)
 | |
|       })
 | |
|       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)
 | |
|       // }
 | |
|     },
 | |
|     removedTagItem(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)
 | |
|       // }
 | |
|     },
 | |
|     removedGenreItem(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() {
 | |
|       // 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(() => {
 | |
|         if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {
 | |
|           this.isScrollable = true
 | |
|         }
 | |
|       })
 | |
|     },
 | |
|     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.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: this.libraryItemCopies[i].id,
 | |
|             mediaPayload: details.updatePayload
 | |
|           })
 | |
|         }
 | |
|       }
 | |
|       if (!updates.length) {
 | |
|         return this.$toast.warning('No updates were made')
 | |
|       }
 | |
| 
 | |
|       console.log('Pushing updates', updates)
 | |
|       this.isProcessing = true
 | |
|       this.$axios
 | |
|         .$post('/api/items/batch/update', updates)
 | |
|         .then((data) => {
 | |
|           this.isProcessing = false
 | |
|           if (data.updates) {
 | |
|             this.$toast.success(`Successfully updated ${data.updates} items`)
 | |
|             this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
 | |
|           } else {
 | |
|             this.$toast.warning(this.$strings.MessageNoUpdatesWereNecessary)
 | |
|           }
 | |
|         })
 | |
|         .catch((error) => {
 | |
|           console.error('failed to batch update', error)
 | |
|           this.$toast.error('Failed to batch update')
 | |
|           this.isProcessing = false
 | |
|         })
 | |
|     }
 | |
|   },
 | |
|   mounted() {
 | |
|     this.init()
 | |
|   }
 | |
| }
 | |
| </script>
 | |
| 
 | |
| <style>
 | |
| .slide-enter-active,
 | |
| .slide-leave-active {
 | |
|   transition: transform 0.2s ease;
 | |
| }
 | |
| 
 | |
| .slide-enter,
 | |
| .slide-leave-to {
 | |
|   transform: translateY(-100%);
 | |
|   transition: all 150ms ease-in 0s;
 | |
| }
 | |
| </style> |