mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Podcast library item card, edit details, batch edit
This commit is contained in:
		
							parent
							
								
									5446aea910
								
							
						
					
					
						commit
						e32d05ea27
					
				| @ -47,10 +47,10 @@ | ||||
|       <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 ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom"> | ||||
|         <ui-tooltip v-if="!isPodcastLibrary" :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" direction="bottom"> | ||||
|           <ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" /> | ||||
|         </ui-tooltip> | ||||
|         <ui-tooltip v-if="userCanUpdate" text="Add to Collection" direction="bottom"> | ||||
|         <ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" 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 && numLibraryItemsSelected < 50"> | ||||
| @ -79,6 +79,12 @@ export default { | ||||
|     libraryName() { | ||||
|       return this.currentLibrary ? this.currentLibrary.name : 'unknown' | ||||
|     }, | ||||
|     libraryMediaType() { | ||||
|       return this.currentLibrary ? this.currentLibrary.mediaType : null | ||||
|     }, | ||||
|     isPodcastLibrary() { | ||||
|       return this.libraryMediaType === 'podcast' | ||||
|     }, | ||||
|     isHome() { | ||||
|       return this.$route.name === 'library-library' | ||||
|     }, | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|       <p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> | ||||
|         {{ displayTitle }} | ||||
|       </p> | ||||
|       <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p> | ||||
|       <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p> | ||||
|       <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> | ||||
|     </div> | ||||
| 
 | ||||
| @ -146,6 +146,12 @@ export default { | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     mediaType() { | ||||
|       return this._libraryItem.mediaType | ||||
|     }, | ||||
|     isPodcast() { | ||||
|       return this.mediaType === 'podcast' | ||||
|     }, | ||||
|     placeholderUrl() { | ||||
|       return '/book_placeholder.jpg' | ||||
|     }, | ||||
| @ -195,6 +201,7 @@ export default { | ||||
|       return this.mediaMetadata.authors || [] | ||||
|     }, | ||||
|     author() { | ||||
|       if (this.isPodcast) return this.mediaMetadata.author | ||||
|       return this.authors.map((au) => au.name).join(', ') | ||||
|     }, | ||||
|     authorLF() { | ||||
| @ -216,6 +223,7 @@ export default { | ||||
|       return this.title | ||||
|     }, | ||||
|     displayAuthor() { | ||||
|       if (this.isPodcast) return this.author | ||||
|       if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF | ||||
|       return this.author | ||||
|     }, | ||||
| @ -301,29 +309,30 @@ export default { | ||||
|       return this.store.getters['user/getIsRoot'] | ||||
|     }, | ||||
|     moreMenuItems() { | ||||
|       var items = [ | ||||
|         { | ||||
|           func: 'toggleFinished', | ||||
|           text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}` | ||||
|         }, | ||||
|         { | ||||
|           func: 'openCollections', | ||||
|           text: 'Add to Collection' | ||||
|         } | ||||
|       ] | ||||
|       var items = [] | ||||
|       if (!this.isPodcast) { | ||||
|         items = [ | ||||
|           { | ||||
|             func: 'toggleFinished', | ||||
|             text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}` | ||||
|           }, | ||||
|           { | ||||
|             func: 'openCollections', | ||||
|             text: 'Add to Collection' | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|       if (this.userCanUpdate) { | ||||
|         if (this.numTracks) { | ||||
|           items.push({ | ||||
|             func: 'showEditModalTracks', | ||||
|             text: 'Tracks' | ||||
|           }) | ||||
|         } | ||||
|         items.push({ | ||||
|           func: 'showEditModalTracks', | ||||
|           text: 'Files' | ||||
|         }) | ||||
|         items.push({ | ||||
|           func: 'showEditModalMatch', | ||||
|           text: 'Match' | ||||
|         }) | ||||
|       } | ||||
|       if (this.userCanDownload) { | ||||
|       if (this.userCanDownload && !this.isPodcast) { | ||||
|         items.push({ | ||||
|           func: 'showEditModalDownload', | ||||
|           text: 'Download' | ||||
|  | ||||
| @ -47,6 +47,11 @@ export default { | ||||
|           title: 'Chapters', | ||||
|           component: 'modals-item-tabs-chapters' | ||||
|         }, | ||||
|         { | ||||
|           id: 'episodes', | ||||
|           title: 'Episodes', | ||||
|           component: 'modals-item-tabs-episodes' | ||||
|         }, | ||||
|         { | ||||
|           id: 'files', | ||||
|           title: 'Files', | ||||
| @ -118,8 +123,10 @@ export default { | ||||
|       if (!this.userCanUpdate && !this.userCanDownload) return [] | ||||
|       return this.tabs.filter((tab) => { | ||||
|         if (tab.id === 'download' && this.isMissing) return false | ||||
|         if ((tab.id === 'download' || tab.id === 'files' || tab.id === 'authors') && this.userCanDownload) return true | ||||
|         if (tab.id !== 'download' && tab.id !== 'files' && tab.id !== 'authors' && this.userCanUpdate) return true | ||||
|         if (tab.id === 'chapters' && this.mediaType !== 'book') return false | ||||
|         if (tab.id === 'episodes' && this.mediaType !== 'podcast') return false | ||||
|         if ((tab.id === 'download' || tab.id === 'files') && this.userCanDownload) return true | ||||
|         if (tab.id !== 'download' && tab.id !== 'files' && this.userCanUpdate) return true | ||||
|         if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true | ||||
|         return false | ||||
|       }) | ||||
| @ -147,6 +154,9 @@ export default { | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     mediaType() { | ||||
|       return this.libraryItem ? this.libraryItem.mediaType : null | ||||
|     }, | ||||
|     title() { | ||||
|       return this.mediaMetadata.title || 'No Title' | ||||
|     }, | ||||
|  | ||||
| @ -1,201 +0,0 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full overflow-hidden px-4 py-6 relative"> | ||||
|     <template v-for="(authorName, index) in searchAuthors"> | ||||
|       <cards-search-author-card :key="index" :author-name="authorName" @match="setSelectedMatch" /> | ||||
|     </template> | ||||
| 
 | ||||
|     <div v-show="processing" class="flex h-full items-center justify-center"> | ||||
|       <p>Loading...</p> | ||||
|     </div> | ||||
|     <div v-if="selectedMatch" class="absolute top-0 left-0 w-full bg-bg h-full p-8 max-h-full overflow-y-auto overflow-x-hidden"> | ||||
|       <div class="flex mb-2"> | ||||
|         <div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="selectedMatch = null"> | ||||
|           <span class="material-icons text-3xl">arrow_back</span> | ||||
|         </div> | ||||
|         <p class="text-xl pl-3">Update Author Details</p> | ||||
|       </div> | ||||
|       <form @submit.prevent="submitMatchUpdate"> | ||||
|         <div v-if="selectedMatch.image" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.image" /> | ||||
|           <img :src="selectedMatch.image" class="w-24 object-contain ml-4" /> | ||||
|           <ui-text-input-with-label v-model="selectedMatch.image" :disabled="!selectedMatchUsage.image" label="Image" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div v-if="selectedMatch.name" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.name" /> | ||||
|           <ui-text-input-with-label v-model="selectedMatch.name" :disabled="!selectedMatchUsage.name" label="Name" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div v-if="selectedMatch.description" class="flex items-center py-2"> | ||||
|           <ui-checkbox v-model="selectedMatchUsage.description" /> | ||||
|           <ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" label="Description" class="flex-grow ml-4" /> | ||||
|         </div> | ||||
|         <div class="flex items-center justify-end py-2"> | ||||
|           <ui-btn color="success" type="submit">Update</ui-btn> | ||||
|         </div> | ||||
|       </form> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       searchAuthors: [], | ||||
|       audiobookId: null, | ||||
|       searchAuthor: null, | ||||
|       lastSearch: null, | ||||
|       hasSearched: false, | ||||
|       selectedMatch: null, | ||||
| 
 | ||||
|       selectedMatchUsage: { | ||||
|         image: true, | ||||
|         name: true, | ||||
|         description: true | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isProcessing: { | ||||
|       get() { | ||||
|         return this.processing | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('update:processing', val) | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     // getSearchQuery() { | ||||
|     //   return `q=${this.searchAuthor}` | ||||
|     // }, | ||||
|     // submitSearch() { | ||||
|     //   if (!this.searchTitle) { | ||||
|     //     this.$toast.warning('Search title is required') | ||||
|     //     return | ||||
|     //   } | ||||
|     //   this.runSearch() | ||||
|     // }, | ||||
|     // async runSearch() { | ||||
|     //   var searchQuery = this.getSearchQuery() | ||||
|     //   if (this.lastSearch === searchQuery) return | ||||
|     //   this.selectedMatch = null | ||||
|     //   this.isProcessing = true | ||||
|     //   this.lastSearch = searchQuery | ||||
|     //   var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => { | ||||
|     //     console.error('Failed', error) | ||||
|     //     return [] | ||||
|     //   }) | ||||
|     //   if (result) { | ||||
|     //     this.selectedMatch = result | ||||
|     //   } | ||||
|     //   this.isProcessing = false | ||||
|     //   this.hasSearched = true | ||||
|     // }, | ||||
|     init() { | ||||
|       this.selectedMatch = null | ||||
|       // this.selectedMatchUsage = { | ||||
|       //   title: true, | ||||
|       //   subtitle: true, | ||||
|       //   cover: true, | ||||
|       //   author: true, | ||||
|       //   description: true, | ||||
|       //   isbn: true, | ||||
|       //   publisher: true, | ||||
|       //   publishYear: true | ||||
|       // } | ||||
| 
 | ||||
|       if (this.audiobook.id !== this.audiobookId) { | ||||
|         this.selectedMatch = null | ||||
|         this.hasSearched = false | ||||
|         this.audiobookId = this.audiobook.id | ||||
|       } | ||||
| 
 | ||||
|       if (!this.audiobook.book || !this.audiobook.book.authorFL) { | ||||
|         this.searchAuthors = [] | ||||
|         return | ||||
|       } | ||||
|       this.searchAuthors = (this.audiobook.book.authorFL || '').split(', ') | ||||
|     }, | ||||
|     selectMatch(match) { | ||||
|       this.selectedMatch = match | ||||
|     }, | ||||
|     buildMatchUpdatePayload() { | ||||
|       var updatePayload = {} | ||||
|       for (const key in this.selectedMatchUsage) { | ||||
|         if (this.selectedMatchUsage[key] && this.selectedMatch[key]) { | ||||
|           updatePayload[key] = this.selectedMatch[key] | ||||
|         } | ||||
|       } | ||||
|       return updatePayload | ||||
|     }, | ||||
|     async submitMatchUpdate() { | ||||
|       var updatePayload = this.buildMatchUpdatePayload() | ||||
|       if (!Object.keys(updatePayload).length) { | ||||
|         return | ||||
|       } | ||||
|       this.isProcessing = true | ||||
| 
 | ||||
|       if (updatePayload.cover) { | ||||
|         var coverPayload = { | ||||
|           url: updatePayload.cover | ||||
|         } | ||||
|         var success = await this.$axios.$post(`/api/items/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|         if (success) { | ||||
|           this.$toast.success('Book Cover Updated') | ||||
|         } else { | ||||
|           this.$toast.error('Book Cover Failed to Update') | ||||
|         } | ||||
|         console.log('Updated cover') | ||||
|         delete updatePayload.cover | ||||
|       } | ||||
| 
 | ||||
|       if (Object.keys(updatePayload).length) { | ||||
|         var bookUpdatePayload = { | ||||
|           book: updatePayload | ||||
|         } | ||||
|         var success = await this.$axios.$patch(`/api/items/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|         if (success) { | ||||
|           this.$toast.success('Book Details Updated') | ||||
|           this.selectedMatch = null | ||||
|           this.$emit('selectTab', 'details') | ||||
|         } else { | ||||
|           this.$toast.error('Book Details Failed to Update') | ||||
|         } | ||||
|       } else { | ||||
|         this.selectedMatch = null | ||||
|       } | ||||
|       this.isProcessing = false | ||||
|     }, | ||||
|     setSelectedMatch(authorMatchObj) { | ||||
|       this.selectedMatch = authorMatchObj | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .matchListWrapper { | ||||
|   height: calc(100% - 80px); | ||||
| } | ||||
| </style> | ||||
| @ -1,6 +1,7 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full relative"> | ||||
|     <widgets-item-details-edit ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> | ||||
|     <widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> | ||||
|     <widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" /> | ||||
| 
 | ||||
|     <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"> | ||||
| @ -8,11 +9,11 @@ | ||||
| 
 | ||||
|         <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-tooltip v-if="!isMissing && mediaType == 'book'" 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-tooltip v-if="mediaType == 'book'" :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> | ||||
| 
 | ||||
| @ -65,6 +66,9 @@ export default { | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     mediaType() { | ||||
|       return this.libraryItem ? this.libraryItem.mediaType : null | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|  | ||||
							
								
								
									
										55
									
								
								client/components/modals/item/tabs/Episodes.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								client/components/modals/item/tabs/Episodes.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,55 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> | ||||
|     <div class="w-full mb-4"> | ||||
|       <div class="w-full p-4 bg-primary"> | ||||
|         <p>Podcast Episodes</p> | ||||
|       </div> | ||||
|       <div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">No Episodes</div> | ||||
|       <table v-else class="text-sm tracksTable"> | ||||
|         <tr class="font-book"> | ||||
|           <th class="text-left w-16"><span class="px-4">#</span></th> | ||||
|           <th class="text-left">Title</th> | ||||
|           <th class="text-center w-28">Duration</th> | ||||
|           <th class="text-center w-28">Size</th> | ||||
|         </tr> | ||||
|         <tr v-for="episode in episodes" :key="episode.id"> | ||||
|           <td class="text-left"> | ||||
|             <p class="px-4">{{ episode.index }}</p> | ||||
|           </td> | ||||
|           <td class="font-book"> | ||||
|             {{ episode.title }} | ||||
|           </td> | ||||
|           <td class="font-mono text-center"> | ||||
|             {{ $secondsToTimestamp(episode.duration) }} | ||||
|           </td> | ||||
|           <td class="font-mono text-center"> | ||||
|             {{ $bytesPretty(episode.size) }} | ||||
|           </td> | ||||
|         </tr> | ||||
|       </table> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return {} | ||||
|   }, | ||||
|   computed: { | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     episodes() { | ||||
|       return this.media.episodes || [] | ||||
|     } | ||||
|   }, | ||||
|   methods: {} | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										224
									
								
								client/components/widgets/PodcastDetailsEdit.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								client/components/widgets/PodcastDetailsEdit.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,224 @@ | ||||
| <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 ref="titleInput" v-model="details.title" label="Title" /> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" /> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" /> | ||||
| 
 | ||||
|         <ui-textarea-with-label ref="descriptionInput" 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/4 px-1"> | ||||
|             <ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" /> | ||||
|           </div> | ||||
|           <div class="w-1/4 px-1"> | ||||
|             <ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" /> | ||||
|           </div> | ||||
|           <div class="w-1/4 px-1"> | ||||
|             <ui-text-input-with-label ref="languageInput" 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> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       details: { | ||||
|         title: null, | ||||
|         author: null, | ||||
|         description: null, | ||||
|         releaseDate: null, | ||||
|         genres: [], | ||||
|         feedUrl: null, | ||||
|         imageUrl: null, | ||||
|         itunesPageUrl: null, | ||||
|         itunesId: null, | ||||
|         itunesArtistId: null, | ||||
|         explicit: false, | ||||
|         language: null | ||||
|       }, | ||||
|       autoDownloadEpisodes: 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 || [] | ||||
|     }, | ||||
|     filterData() { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     getDetails() { | ||||
|       this.forceBlur() | ||||
|       return this.checkForChanges() | ||||
|     }, | ||||
|     getTitleAndAuthorName() { | ||||
|       this.forceBlur() | ||||
|       return { | ||||
|         title: this.details.title, | ||||
|         author: this.details.author | ||||
|       } | ||||
|     }, | ||||
|     mapBatchDetails(batchDetails) { | ||||
|       for (const key in batchDetails) { | ||||
|         if (key === 'tags') { | ||||
|           this.newTags = [...batchDetails.tags] | ||||
|         } else if (key === 'genres') { | ||||
|           this.details[key] = [...batchDetails[key]] | ||||
|         } else { | ||||
|           this.details[key] = batchDetails[key] | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     forceBlur() { | ||||
|       if (this.$refs.titleInput) this.$refs.titleInput.blur() | ||||
|       if (this.$refs.authorInput) this.$refs.authorInput.blur() | ||||
|       if (this.$refs.releaseDateInput) this.$refs.releaseDateInput.blur() | ||||
|       if (this.$refs.descriptionInput) this.$refs.descriptionInput.blur() | ||||
|       if (this.$refs.feedUrlInput) this.$refs.feedUrlInput.blur() | ||||
|       if (this.$refs.itunesIdInput) this.$refs.itunesIdInput.blur() | ||||
|       if (this.$refs.languageInput) this.$refs.languageInput.blur() | ||||
| 
 | ||||
|       if (this.$refs.genresSelect && this.$refs.genresSelect.isFocused) { | ||||
|         this.$refs.genresSelect.forceBlur() | ||||
|       } | ||||
|       if (this.$refs.tagsSelect && this.$refs.tagsSelect.isFocused) { | ||||
|         this.$refs.tagsSelect.forceBlur() | ||||
|       } | ||||
|     }, | ||||
|     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 === 'genres') { | ||||
|           // Check array of strings | ||||
|           if (!this.stringArrayEqual(newValue, oldValue)) { | ||||
|             metadata[key] = [...newValue] | ||||
|           } | ||||
|         } 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.author = this.mediaMetadata.author || '' | ||||
|       this.details.description = this.mediaMetadata.description || '' | ||||
|       this.details.releaseDate = this.mediaMetadata.releaseDate || '' | ||||
|       this.details.genres = [...(this.mediaMetadata.genres || [])] | ||||
|       this.details.feedUrl = this.mediaMetadata.feedUrl || '' | ||||
|       this.details.imageUrl = this.mediaMetadata.imageUrl || '' | ||||
|       this.details.itunesPageUrl = this.mediaMetadata.itunesPageUrl || '' | ||||
|       this.details.itunesId = this.mediaMetadata.itunesId || '' | ||||
|       this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || '' | ||||
|       this.details.language = this.mediaMetadata.language || '' | ||||
|       this.details.explicit = !!this.mediaMetadata.explicit | ||||
| 
 | ||||
|       this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes | ||||
|       this.newTags = [...(this.media.tags || [])] | ||||
|     }, | ||||
|     submitForm() { | ||||
|       this.$emit('submit') | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -9,20 +9,20 @@ | ||||
|       <div class="overflow-hidden"> | ||||
|         <transition name="slide"> | ||||
|           <div v-if="openMapOptions" class="flex flex-wrap"> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.subtitle" /> | ||||
|               <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"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <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"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.publishedYear" /> | ||||
|               <ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" label="Publish Year" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
|             <div class="flex items-center px-4 w-1/2"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.series" /> | ||||
|               <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> | ||||
| @ -34,11 +34,11 @@ | ||||
|               <ui-checkbox v-model="selectedBatchUsage.tags" /> | ||||
|               <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"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <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"> | ||||
|             <div v-if="!isPodcastLibrary" class="flex items-center px-4 w-1/2"> | ||||
|               <ui-checkbox v-model="selectedBatchUsage.publisher" /> | ||||
|               <ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" label="Publisher" class="mb-4 ml-4" /> | ||||
|             </div> | ||||
| @ -72,7 +72,8 @@ | ||||
|     <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-item-details-edit :ref="`itemForm-${libraryItem.id}`" :library-item="libraryItem" /> | ||||
|           <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> | ||||
| @ -99,6 +100,7 @@ export default { | ||||
|       return [] | ||||
|     }) | ||||
|     return { | ||||
|       mediaType: libraryItems[0].mediaType, | ||||
|       libraryItems | ||||
|     } | ||||
|   }, | ||||
| @ -139,6 +141,9 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isPodcastLibrary() { | ||||
|       return this.mediaType === 'podcast' | ||||
|     }, | ||||
|     coverAspectRatio() { | ||||
|       return this.$store.getters['getServerSetting']('coverAspectRatio') | ||||
|     }, | ||||
|  | ||||
| @ -31,7 +31,7 @@ | ||||
|                 <p v-if="bookSubtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ bookSubtitle }}</p> | ||||
|               </div> | ||||
| 
 | ||||
|               <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor }}</p> | ||||
|               <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p> | ||||
|               <p v-else-if="authorsList.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl"> | ||||
|                 by <nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link> | ||||
|               </p> | ||||
| @ -126,15 +126,15 @@ | ||||
|               <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top"> | ||||
|             <ui-tooltip v-if="userCanDownload && !isPodcast" :disabled="isMissing" text="Download" direction="top"> | ||||
|               <ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top"> | ||||
|             <ui-tooltip v-if="!isPodcast" :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top"> | ||||
|               <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip text="Collections" direction="top"> | ||||
|             <ui-tooltip v-if="!isPodcast" text="Collections" direction="top"> | ||||
|               <ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" /> | ||||
|             </ui-tooltip> | ||||
|           </div> | ||||
|  | ||||
| @ -59,6 +59,26 @@ class PodcastEpisode { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONExpanded() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       index: this.index, | ||||
|       episode: this.episode, | ||||
|       episodeType: this.episodeType, | ||||
|       title: this.title, | ||||
|       subtitle: this.subtitle, | ||||
|       description: this.description, | ||||
|       enclosure: this.enclosure ? { ...this.enclosure } : null, | ||||
|       pubDate: this.pubDate, | ||||
|       audioFile: this.audioFile.toJSON(), | ||||
|       publishedAt: this.publishedAt, | ||||
|       addedAt: this.addedAt, | ||||
|       updatedAt: this.updatedAt, | ||||
|       duration: this.duration, | ||||
|       size: this.size | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get tracks() { | ||||
|     return [this.audioFile] | ||||
|   } | ||||
|  | ||||
| @ -47,7 +47,8 @@ class Podcast { | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       episodes: this.episodes.map(e => e.toJSON()), | ||||
|       autoDownloadEpisodes: this.autoDownloadEpisodes | ||||
|       autoDownloadEpisodes: this.autoDownloadEpisodes, | ||||
|       size: this.size | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -56,8 +57,9 @@ class Podcast { | ||||
|       metadata: this.metadata.toJSONExpanded(), | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       episodes: this.episodes.map(e => e.toJSON()), | ||||
|       autoDownloadEpisodes: this.autoDownloadEpisodes | ||||
|       episodes: this.episodes.map(e => e.toJSONExpanded()), | ||||
|       autoDownloadEpisodes: this.autoDownloadEpisodes, | ||||
|       size: this.size | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -1,3 +1,6 @@ | ||||
| const Logger = require('../../Logger') | ||||
| const { areEquivalent, copyValue } = require('../../utils/index') | ||||
| 
 | ||||
| class PodcastMetadata { | ||||
|   constructor(metadata) { | ||||
|     this.title = null | ||||
| @ -87,5 +90,20 @@ class PodcastMetadata { | ||||
|       this.genres = [...mediaMetadata.genres] | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   update(payload) { | ||||
|     var json = this.toJSON() | ||||
|     var hasUpdates = false | ||||
|     for (const key in json) { | ||||
|       if (payload[key] !== undefined) { | ||||
|         if (!areEquivalent(payload[key], json[key])) { | ||||
|           this[key] = copyValue(payload[key]) | ||||
|           Logger.debug('[PodcastMetadata] Key updated', key, this[key]) | ||||
|           hasUpdates = true | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return hasUpdates | ||||
|   } | ||||
| } | ||||
| module.exports = PodcastMetadata | ||||
| @ -120,12 +120,11 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems) { | ||||
|   return libraryItemGroup | ||||
| } | ||||
| 
 | ||||
| function cleanFileObjects(libraryItemPath, folderPath, files) { | ||||
| function cleanFileObjects(libraryItemPath, files) { | ||||
|   return Promise.all(files.map(async (file) => { | ||||
|     var filePath = Path.posix.join(libraryItemPath, file) | ||||
|     var relFilePath = filePath.replace(folderPath, '') | ||||
|     var newLibraryFile = new LibraryFile() | ||||
|     await newLibraryFile.setDataFromPath(filePath, relFilePath) | ||||
|     await newLibraryFile.setDataFromPath(filePath, file) | ||||
|     return newLibraryFile | ||||
|   })) | ||||
| } | ||||
| @ -153,7 +152,7 @@ async function scanFolder(libraryMediaType, folder, serverSettings = {}) { | ||||
|   for (const libraryItemPath in libraryItemGrouping) { | ||||
|     var libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) | ||||
| 
 | ||||
|     var fileObjs = await cleanFileObjects(libraryItemData.path, folderPath, libraryItemGrouping[libraryItemPath]) | ||||
|     var fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemGrouping[libraryItemPath]) | ||||
|     var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) | ||||
|     items.push({ | ||||
|       folderId: folder.id, | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user