mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Podcast episode match tab and find episode by title api route
This commit is contained in:
		
							parent
							
								
									f702c02859
								
							
						
					
					
						commit
						516c5c3308
					
				| @ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div class="w-full border-b border-gray-700 pb-2"> | ||||
|   <div v-if="book" class="w-full border-b border-gray-700 pb-2"> | ||||
|     <div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch"> | ||||
|       <div class="h-24 bg-primary" :style="{ minWidth: 96 / bookCoverAspectRatio + 'px' }"> | ||||
|         <img v-if="selectedCover" :src="selectedCover" class="h-full w-full object-contain" /> | ||||
| @ -25,7 +25,7 @@ | ||||
|       <div v-else class="px-4 flex-grow"> | ||||
|         <h1>{{ book.title }}</h1> | ||||
|         <p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p> | ||||
|         <p class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p> | ||||
|         <p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p> | ||||
|         <p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p> | ||||
|       </div> | ||||
|     </div> | ||||
| @ -63,7 +63,7 @@ export default { | ||||
|     selectMatch() { | ||||
|       var book = { ...this.book } | ||||
|       book.cover = this.selectedCover | ||||
|       this.$emit('select', this.book) | ||||
|       this.$emit('select', book) | ||||
|     }, | ||||
|     clickCover(cover) { | ||||
|       this.selectedCover = cover | ||||
|  | ||||
| @ -5,33 +5,14 @@ | ||||
|         <p class="font-book text-3xl text-white truncate">{{ title }}</p> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh"> | ||||
|       <div class="flex flex-wrap"> | ||||
|         <div class="w-1/5 p-1"> | ||||
|           <ui-text-input-with-label v-model="newEpisode.season" label="Season" /> | ||||
|         </div> | ||||
|         <div class="w-1/5 p-1"> | ||||
|           <ui-text-input-with-label v-model="newEpisode.episode" label="Episode" /> | ||||
|         </div> | ||||
|         <div class="w-1/5 p-1"> | ||||
|           <ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" /> | ||||
|         </div> | ||||
|         <div class="w-2/5 p-1"> | ||||
|           <ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" /> | ||||
|         </div> | ||||
|         <div class="w-full p-1"> | ||||
|           <ui-text-input-with-label v-model="newEpisode.title" label="Title" /> | ||||
|         </div> | ||||
|         <div class="w-full p-1"> | ||||
|           <ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" /> | ||||
|         </div> | ||||
|         <div class="w-full p-1 default-style"> | ||||
|           <ui-rich-text-editor v-if="show" label="Description" v-model="newEpisode.description" /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="flex justify-end pt-4"> | ||||
|         <ui-btn @click="submit">Submit</ui-btn> | ||||
|       </div> | ||||
|     <div class="absolute -top-10 left-0 z-10 w-full flex"> | ||||
|       <template v-for="tab in tabs"> | ||||
|         <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> | ||||
|       </template> | ||||
|     </div> | ||||
| 
 | ||||
|     <div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh"> | ||||
|       <component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> | ||||
|     </div> | ||||
|   </modals-modal> | ||||
| </template> | ||||
| @ -41,25 +22,19 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       processing: false, | ||||
|       newEpisode: { | ||||
|         season: null, | ||||
|         episode: null, | ||||
|         episodeType: null, | ||||
|         title: null, | ||||
|         subtitle: null, | ||||
|         description: null, | ||||
|         pubDate: null, | ||||
|         publishedAt: null | ||||
|       }, | ||||
|       pubDateInput: null | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     episode: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|       selectedTab: 'details', | ||||
|       tabs: [ | ||||
|         { | ||||
|           id: 'details', | ||||
|           title: 'Details', | ||||
|           component: 'modals-podcast-tabs-episode-details' | ||||
|         }, | ||||
|         { | ||||
|           id: 'match', | ||||
|           title: 'Match', | ||||
|           component: 'modals-podcast-tabs-episode-match' | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
| @ -77,67 +52,29 @@ export default { | ||||
|     episode() { | ||||
|       return this.$store.state.globals.selectedEpisode | ||||
|     }, | ||||
|     episodeId() { | ||||
|       return this.episode ? this.episode.id : null | ||||
|     }, | ||||
|     title() { | ||||
|       if (!this.libraryItem) return '' | ||||
|       return this.libraryItem.media.metadata.title || 'Unknown' | ||||
|     }, | ||||
|     tabComponentName() { | ||||
|       var _tab = this.tabs.find((t) => t.id === this.selectedTab) | ||||
|       return _tab ? _tab.component : '' | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     updatePubDate(val) { | ||||
|       if (val) { | ||||
|         this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx') | ||||
|         this.newEpisode.publishedAt = new Date(val).valueOf() | ||||
|       } else { | ||||
|         this.newEpisode.pubDate = null | ||||
|         this.newEpisode.publishedAt = null | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.newEpisode.season = this.episode.season || '' | ||||
|       this.newEpisode.episode = this.episode.episode || '' | ||||
|       this.newEpisode.episodeType = this.episode.episodeType || '' | ||||
|       this.newEpisode.title = this.episode.title || '' | ||||
|       this.newEpisode.subtitle = this.episode.subtitle || '' | ||||
|       this.newEpisode.description = this.episode.description || '' | ||||
|       this.newEpisode.pubDate = this.episode.pubDate || '' | ||||
|       this.newEpisode.publishedAt = this.episode.publishedAt | ||||
| 
 | ||||
|       this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null | ||||
|     }, | ||||
|     getUpdatePayload() { | ||||
|       var updatePayload = {} | ||||
|       for (const key in this.newEpisode) { | ||||
|         if (this.newEpisode[key] != this.episode[key]) { | ||||
|           updatePayload[key] = this.newEpisode[key] | ||||
|         } | ||||
|       } | ||||
|       return updatePayload | ||||
|     }, | ||||
|     submit() { | ||||
|       const payload = this.getUpdatePayload() | ||||
|       if (!Object.keys(payload).length) { | ||||
|         return this.$toast.info('No updates were made') | ||||
|       } | ||||
| 
 | ||||
|       this.processing = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload) | ||||
|         .then(() => { | ||||
|           this.processing = false | ||||
|           this.$toast.success('Podcast episode updated') | ||||
|           this.show = false | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           var errorMsg = error.response && error.response.data ? error.response.data : 'Failed update episode' | ||||
|           console.error('Failed update episode', error) | ||||
|           this.processing = false | ||||
|           this.$toast.error(errorMsg) | ||||
|         }) | ||||
|     selectTab(tab) { | ||||
|       this.selectedTab = tab | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style scoped> | ||||
| .tab { | ||||
|   height: 40px; | ||||
| } | ||||
| .tab.tab-selected { | ||||
|   height: 41px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										136
									
								
								client/components/modals/podcast/tabs/EpisodeDetails.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								client/components/modals/podcast/tabs/EpisodeDetails.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,136 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="flex flex-wrap"> | ||||
|       <div class="w-1/5 p-1"> | ||||
|         <ui-text-input-with-label v-model="newEpisode.season" label="Season" /> | ||||
|       </div> | ||||
|       <div class="w-1/5 p-1"> | ||||
|         <ui-text-input-with-label v-model="newEpisode.episode" label="Episode" /> | ||||
|       </div> | ||||
|       <div class="w-1/5 p-1"> | ||||
|         <ui-text-input-with-label v-model="newEpisode.episodeType" label="Episode Type" /> | ||||
|       </div> | ||||
|       <div class="w-2/5 p-1"> | ||||
|         <ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" label="Pub Date" /> | ||||
|       </div> | ||||
|       <div class="w-full p-1"> | ||||
|         <ui-text-input-with-label v-model="newEpisode.title" label="Title" /> | ||||
|       </div> | ||||
|       <div class="w-full p-1"> | ||||
|         <ui-textarea-with-label v-model="newEpisode.subtitle" label="Subtitle" :rows="3" /> | ||||
|       </div> | ||||
|       <div class="w-full p-1 default-style"> | ||||
|         <ui-rich-text-editor label="Description" v-model="newEpisode.description" /> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="flex justify-end pt-4"> | ||||
|       <ui-btn @click="submit">Submit</ui-btn> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     }, | ||||
|     episode: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       newEpisode: { | ||||
|         season: null, | ||||
|         episode: null, | ||||
|         episodeType: null, | ||||
|         title: null, | ||||
|         subtitle: null, | ||||
|         description: null, | ||||
|         pubDate: null, | ||||
|         publishedAt: null | ||||
|       }, | ||||
|       pubDateInput: null | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     episode: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isProcessing: { | ||||
|       get() { | ||||
|         return this.processing | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('update:processing', val) | ||||
|       } | ||||
|     }, | ||||
|     episodeId() { | ||||
|       return this.episode ? this.episode.id : null | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     updatePubDate(val) { | ||||
|       if (val) { | ||||
|         this.newEpisode.pubDate = this.$formatJsDate(new Date(val), 'E, d MMM yyyy HH:mm:ssxx') | ||||
|         this.newEpisode.publishedAt = new Date(val).valueOf() | ||||
|       } else { | ||||
|         this.newEpisode.pubDate = null | ||||
|         this.newEpisode.publishedAt = null | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.newEpisode.season = this.episode.season || '' | ||||
|       this.newEpisode.episode = this.episode.episode || '' | ||||
|       this.newEpisode.episodeType = this.episode.episodeType || '' | ||||
|       this.newEpisode.title = this.episode.title || '' | ||||
|       this.newEpisode.subtitle = this.episode.subtitle || '' | ||||
|       this.newEpisode.description = this.episode.description || '' | ||||
|       this.newEpisode.pubDate = this.episode.pubDate || '' | ||||
|       this.newEpisode.publishedAt = this.episode.publishedAt | ||||
| 
 | ||||
|       this.pubDateInput = this.episode.pubDate ? this.$formatJsDate(new Date(this.episode.pubDate), "yyyy-MM-dd'T'HH:mm") : null | ||||
|     }, | ||||
|     getUpdatePayload() { | ||||
|       var updatePayload = {} | ||||
|       for (const key in this.newEpisode) { | ||||
|         if (this.newEpisode[key] != this.episode[key]) { | ||||
|           updatePayload[key] = this.newEpisode[key] | ||||
|         } | ||||
|       } | ||||
|       return updatePayload | ||||
|     }, | ||||
|     submit() { | ||||
|       const payload = this.getUpdatePayload() | ||||
|       if (!Object.keys(payload).length) { | ||||
|         return this.$toast.info('No updates were made') | ||||
|       } | ||||
| 
 | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload) | ||||
|         .then(() => { | ||||
|           this.isProcessing = false | ||||
|           this.$toast.success('Podcast episode updated') | ||||
|           this.$emit('close') | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode' | ||||
|           console.error('Failed update episode', error) | ||||
|           this.isProcessing = false | ||||
|           this.$toast.error(errorMsg) | ||||
|         }) | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										156
									
								
								client/components/modals/podcast/tabs/EpisodeMatch.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								client/components/modals/podcast/tabs/EpisodeMatch.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,156 @@ | ||||
| <template> | ||||
|   <div style="min-height: 200px"> | ||||
|     <template v-if="!podcastFeedUrl"> | ||||
|       <div class="py-8"> | ||||
|         <widgets-alert type="error">Podcast has no RSS feed url to use for matching</widgets-alert> | ||||
|       </div> | ||||
|     </template> | ||||
|     <template v-else> | ||||
|       <form @submit.prevent="submitForm"> | ||||
|         <div class="flex mb-2"> | ||||
|           <ui-text-input-with-label v-model="episodeTitle" :disabled="isProcessing" label="Episode Title" class="pr-1" /> | ||||
|           <ui-btn class="mt-5 ml-1" :loading="isProcessing" type="submit">Search</ui-btn> | ||||
|         </div> | ||||
|       </form> | ||||
|       <div v-if="!isProcessing && searchedTitle && !episodesFound.length" class="w-full py-8"> | ||||
|         <p class="text-center text-lg">No episode matches found</p> | ||||
|       </div> | ||||
|       <div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)"> | ||||
|         <p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p> | ||||
|         <p class="break-words mb-1">{{ episode.title }}</p> | ||||
|         <p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p> | ||||
|         <p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     }, | ||||
|     episode: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       episodeTitle: '', | ||||
|       searchedTitle: '', | ||||
|       episodesFound: [] | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     episode: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     isProcessing: { | ||||
|       get() { | ||||
|         return this.processing | ||||
|       }, | ||||
|       set(val) { | ||||
|         this.$emit('update:processing', val) | ||||
|       } | ||||
|     }, | ||||
|     episodeId() { | ||||
|       return this.episode ? this.episode.id : null | ||||
|     }, | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     podcastFeedUrl() { | ||||
|       return this.mediaMetadata.feedUrl | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     getUpdatePayload(episodeData) { | ||||
|       var updatePayload = {} | ||||
|       for (const key in episodeData) { | ||||
|         if (key === 'enclosure') { | ||||
|           if (!this.episode.enclosure || JSON.stringify(this.episode.enclosure) !== JSON.stringify(episodeData.enclosure)) { | ||||
|             updatePayload[key] = { | ||||
|               ...episodeData.enclosure | ||||
|             } | ||||
|           } | ||||
|         } else if (episodeData[key] != this.episode[key]) { | ||||
|           updatePayload[key] = episodeData[key] | ||||
|         } | ||||
|       } | ||||
|       return updatePayload | ||||
|     }, | ||||
|     selectEpisode(episode) { | ||||
|       const episodeData = { | ||||
|         title: episode.title || '', | ||||
|         subtitle: episode.subtitle || '', | ||||
|         description: episode.description || '', | ||||
|         enclosure: episode.enclosure || null, | ||||
|         episode: episode.episode || '', | ||||
|         episodeType: episode.episodeType || '', | ||||
|         season: episode.season || '', | ||||
|         pubDate: episode.pubDate || '', | ||||
|         publishedAt: episode.publishedAt | ||||
|       } | ||||
|       const updatePayload = this.getUpdatePayload(episodeData) | ||||
|       if (!Object.keys(updatePayload).length) { | ||||
|         return this.$toast.info('No updates are necessary') | ||||
|       } | ||||
|       console.log('Episode update payload', updatePayload) | ||||
| 
 | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessing = false | ||||
|           this.$toast.success('Podcast episode updated') | ||||
|           this.$emit('selectTab', 'details') | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode' | ||||
|           console.error('Failed update episode', error) | ||||
|           this.isProcessing = false | ||||
|           this.$toast.error(errorMsg) | ||||
|         }) | ||||
|     }, | ||||
|     submitForm() { | ||||
|       if (!this.episodeTitle || !this.episodeTitle.length) { | ||||
|         this.$toast.error('Must enter an episode title') | ||||
|         return | ||||
|       } | ||||
|       this.searchedTitle = this.episodeTitle | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$get(`/api/podcasts/${this.libraryItem.id}/search-episode?title=${this.episodeTitle}`) | ||||
|         .then((results) => { | ||||
|           this.episodesFound = results.episodes.map((ep) => ep.episode) | ||||
|           console.log('Episodes found', this.episodesFound) | ||||
|           this.isProcessing = false | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed to search for episode', error) | ||||
|           var errMsg = error.response ? error.response.data || '' : '' | ||||
|           this.$toast.error(errMsg || 'Failed to search for episode') | ||||
|           this.isProcessing = false | ||||
|         }) | ||||
|     }, | ||||
|     init() { | ||||
|       this.searchedTitle = null | ||||
|       this.episodesFound = [] | ||||
|       this.episodeTitle = this.episode ? this.episode.title || '' : '' | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -211,6 +211,12 @@ export default { | ||||
|     libraryItemUpdated(libraryItem) { | ||||
|       if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) { | ||||
|         this.$store.commit('setSelectedLibraryItem', libraryItem) | ||||
|         if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') { | ||||
|           const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id) | ||||
|           if (episode) { | ||||
|             this.$store.commit('globals/setSelectedEpisode', episode) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) | ||||
|       this.$store.commit('libraries/updateFilterDataWithItem', libraryItem) | ||||
|  | ||||
| @ -164,6 +164,25 @@ class PodcastController { | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async findEpisode(req, res) { | ||||
|     const rssFeedUrl = req.libraryItem.media.metadata.feedUrl | ||||
|     if (!rssFeedUrl) { | ||||
|       Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) | ||||
|       return res.status(500).send('Podcast does not have an RSS feed URL') | ||||
|     } | ||||
| 
 | ||||
|     var searchTitle = req.query.title | ||||
|     if (!searchTitle) { | ||||
|       return res.sendStatus(500) | ||||
|     } | ||||
|     searchTitle = searchTitle.toLowerCase().trim() | ||||
| 
 | ||||
|     const episodes = await this.podcastManager.findEpisode(rssFeedUrl, searchTitle) | ||||
|     res.json({ | ||||
|       episodes: episodes || [] | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async downloadEpisodes(req, res) { | ||||
|     if (!req.user.isAdminOrUp) { | ||||
|       Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user) | ||||
| @ -185,7 +204,7 @@ class PodcastController { | ||||
| 
 | ||||
|     var episodeId = req.params.episodeId | ||||
|     if (!libraryItem.media.checkHasEpisode(episodeId)) { | ||||
|       return res.status(500).send('Episode not found') | ||||
|       return res.status(404).send('Episode not found') | ||||
|     } | ||||
| 
 | ||||
|     var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body) | ||||
|  | ||||
| @ -6,6 +6,7 @@ const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| const { downloadFile } = require('../utils/fileUtils') | ||||
| const { levenshteinDistance } = require('../utils/index') | ||||
| const opmlParser = require('../utils/parsers/parseOPML') | ||||
| const prober = require('../utils/prober') | ||||
| const LibraryFile = require('../objects/files/LibraryFile') | ||||
| @ -259,6 +260,37 @@ class PodcastManager { | ||||
|     return newEpisodes | ||||
|   } | ||||
| 
 | ||||
|   async findEpisode(rssFeedUrl, searchTitle) { | ||||
|     const feed = await this.getPodcastFeed(rssFeedUrl).catch(() => { | ||||
|       return null | ||||
|     }) | ||||
|     if (!feed || !feed.episodes) { | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     const matches = [] | ||||
|     feed.episodes.forEach(ep => { | ||||
|       if (!ep.title) return | ||||
| 
 | ||||
|       const epTitle = ep.title.toLowerCase().trim() | ||||
|       if (epTitle === searchTitle) { | ||||
|         matches.push({ | ||||
|           episode: ep, | ||||
|           levenshtein: 0 | ||||
|         }) | ||||
|       } else { | ||||
|         const levenshtein = levenshteinDistance(searchTitle, epTitle, true) | ||||
|         if (levenshtein <= 6 && epTitle.length > levenshtein) { | ||||
|           matches.push({ | ||||
|             episode: ep, | ||||
|             levenshtein | ||||
|           }) | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|     return matches.sort((a, b) => a.levenshtein - b.levenshtein) | ||||
|   } | ||||
| 
 | ||||
|   getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) { | ||||
|     Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`) | ||||
|     return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => { | ||||
| @ -273,7 +305,7 @@ class PodcastManager { | ||||
|       } | ||||
|       return payload.podcast | ||||
|     }).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       Logger.error('[PodcastManager] getPodcastFeed Error', error) | ||||
|       return false | ||||
|     }) | ||||
|   } | ||||
|  | ||||
| @ -189,6 +189,7 @@ class ApiRouter { | ||||
|     this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this)) | ||||
|     this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this)) | ||||
|     this.router.get('/podcasts/:id/clear-queue', PodcastController.middleware.bind(this), PodcastController.clearEpisodeDownloadQueue.bind(this)) | ||||
|     this.router.get('/podcasts/:id/search-episode', PodcastController.middleware.bind(this), PodcastController.findEpisode.bind(this)) | ||||
|     this.router.post('/podcasts/:id/download-episodes', PodcastController.middleware.bind(this), PodcastController.downloadEpisodes.bind(this)) | ||||
|     this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this)) | ||||
|     this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this)) | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user